list.go

   1package list
   2
   3import (
   4	"slices"
   5	"strings"
   6	"sync"
   7
   8	"github.com/charmbracelet/bubbles/v2/key"
   9	tea "github.com/charmbracelet/bubbletea/v2"
  10	"github.com/charmbracelet/crush/internal/csync"
  11	"github.com/charmbracelet/crush/internal/tui/components/anim"
  12	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  13	"github.com/charmbracelet/crush/internal/tui/styles"
  14	"github.com/charmbracelet/crush/internal/tui/util"
  15	"github.com/charmbracelet/lipgloss/v2"
  16	uv "github.com/charmbracelet/ultraviolet"
  17	"github.com/charmbracelet/x/ansi"
  18	"github.com/rivo/uniseg"
  19)
  20
  21type Item interface {
  22	util.Model
  23	layout.Sizeable
  24	ID() string
  25}
  26
  27type HasAnim interface {
  28	Item
  29	Spinning() bool
  30}
  31
  32type List[T Item] interface {
  33	util.Model
  34	layout.Sizeable
  35	layout.Focusable
  36
  37	// Just change state
  38	MoveUp(int) tea.Cmd
  39	MoveDown(int) tea.Cmd
  40	GoToTop() tea.Cmd
  41	GoToBottom() tea.Cmd
  42	SelectItemAbove() tea.Cmd
  43	SelectItemBelow() tea.Cmd
  44	SetItems([]T) tea.Cmd
  45	SetSelected(string) tea.Cmd
  46	SelectedItem() *T
  47	Items() []T
  48	UpdateItem(string, T) tea.Cmd
  49	DeleteItem(string) tea.Cmd
  50	PrependItem(T) tea.Cmd
  51	AppendItem(T) tea.Cmd
  52	StartSelection(col, line int)
  53	EndSelection(col, line int)
  54	SelectionStop()
  55	SelectionClear()
  56	SelectWord(col, line int)
  57	SelectParagraph(col, line int)
  58	GetSelectedText(paddingLeft int) string
  59	HasSelection() bool
  60}
  61
  62type direction int
  63
  64const (
  65	DirectionForward direction = iota
  66	DirectionBackward
  67)
  68
  69const (
  70	ItemNotFound              = -1
  71	ViewportDefaultScrollSize = 2
  72)
  73
  74type itemPosition struct {
  75	height int
  76	start  int
  77	end    int
  78}
  79
  80type confOptions struct {
  81	width, height int
  82	gap           int
  83	// if you are at the last item and go down it will wrap to the top
  84	wrap          bool
  85	keyMap        KeyMap
  86	direction     direction
  87	selectedIndex int // Changed from string to int for index-based selection
  88	focused       bool
  89	resize        bool
  90	enableMouse   bool
  91}
  92
  93type list[T Item] struct {
  94	*confOptions
  95
  96	offset int
  97
  98	indexMap *csync.Map[string, int]
  99	items    *csync.Slice[T]
 100
 101	// Virtual scrolling fields - using slices for O(1) index access
 102	itemPositions                []itemPosition             // Position info for each item by index
 103	virtualHeight                int                        // Total height of all items
 104	viewCache                    *csync.Map[string, string] // Optional cache for rendered views
 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		start = l.offset
 556		if l.virtualHeight > 0 {
 557			end = min(l.offset+l.height-1, l.virtualHeight-1)
 558		} else {
 559			end = l.offset + l.height - 1
 560		}
 561	} else {
 562		// For backward direction
 563		if l.virtualHeight > 0 {
 564			end = l.virtualHeight - l.offset - 1
 565			start = max(0, end-l.height+1)
 566		} else {
 567			end = 0
 568			start = 0
 569		}
 570	}
 571	return start, end
 572}
 573
 574func (l *list[T]) render() tea.Cmd {
 575	return l.renderWithScrollToSelection(true)
 576}
 577
 578func (l *list[T]) renderWithScrollToSelection(scrollToSelection bool) tea.Cmd {
 579	if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
 580		return nil
 581	}
 582	l.setDefaultSelected()
 583
 584	var focusChangeCmd tea.Cmd
 585	if l.focused {
 586		focusChangeCmd = l.focusSelectedItem()
 587	} else {
 588		focusChangeCmd = l.blurSelectedItem()
 589	}
 590
 591	if l.shouldCalculateItemPositions {
 592		l.calculateItemPositions()
 593		l.shouldCalculateItemPositions = false
 594	}
 595
 596	// Scroll to selected item BEFORE rendering if focused and requested
 597	if l.focused && scrollToSelection {
 598		l.scrollToSelection()
 599	}
 600
 601	// Render only visible items
 602	l.renderMu.Lock()
 603	l.rendered = l.renderVirtualScrolling()
 604	l.renderMu.Unlock()
 605
 606	return focusChangeCmd
 607}
 608
 609func (l *list[T]) setDefaultSelected() {
 610	if l.selectedIndex < 0 {
 611		if l.direction == DirectionForward {
 612			l.selectFirstItem()
 613		} else {
 614			l.selectLastItem()
 615		}
 616	}
 617}
 618
 619func (l *list[T]) scrollToSelection() {
 620	if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
 621		return
 622	}
 623
 624	inx := l.selectedIndex
 625	if inx < 0 || inx >= len(l.itemPositions) {
 626		l.selectedIndex = -1
 627		l.setDefaultSelected()
 628		return
 629	}
 630
 631	rItem := l.itemPositions[inx]
 632
 633	start, end := l.viewPosition()
 634
 635	// item bigger or equal to the viewport - show from start
 636	if rItem.height >= l.height {
 637		if l.direction == DirectionForward {
 638			l.offset = rItem.start
 639		} else {
 640			// For backward direction, we want to show the bottom of the item
 641			// offset = 0 means bottom of list is visible
 642			l.offset = 0
 643		}
 644		return
 645	}
 646
 647	// if we are moving by item we want to move the offset so that the
 648	// whole item is visible not just portions of it
 649	if l.movingByItem {
 650		if rItem.start >= start && rItem.end <= end {
 651			// Item is fully visible, no need to scroll
 652			return
 653		}
 654		defer func() { l.movingByItem = false }()
 655	} else {
 656		// item already in view do nothing
 657		if rItem.start >= start && rItem.start <= end {
 658			return
 659		}
 660		if rItem.end >= start && rItem.end <= end {
 661			return
 662		}
 663	}
 664
 665	// If item is above the viewport, make it the first item
 666	if rItem.start < start {
 667		if l.direction == DirectionForward {
 668			l.offset = rItem.start
 669		} else {
 670			if l.virtualHeight > 0 {
 671				l.offset = l.virtualHeight - rItem.end
 672			} else {
 673				l.offset = 0
 674			}
 675		}
 676	} else if rItem.end > end {
 677		// If item is below the viewport, make it the last item
 678		if l.direction == DirectionForward {
 679			l.offset = max(0, rItem.end-l.height+1)
 680		} else {
 681			if l.virtualHeight > 0 {
 682				l.offset = max(0, l.virtualHeight-rItem.start-l.height+1)
 683			} else {
 684				l.offset = 0
 685			}
 686		}
 687	}
 688}
 689
 690func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 691	if l.selectedIndex < 0 || l.selectedIndex >= len(l.itemPositions) {
 692		return nil
 693	}
 694
 695	rItem := l.itemPositions[l.selectedIndex]
 696	start, end := l.viewPosition()
 697	// item bigger than the viewport do nothing
 698	if rItem.start <= start && rItem.end >= end {
 699		return nil
 700	}
 701	// item already in view do nothing
 702	if rItem.start >= start && rItem.end <= end {
 703		return nil
 704	}
 705
 706	itemMiddle := rItem.start + rItem.height/2
 707
 708	if itemMiddle < start {
 709		// select the first item in the viewport
 710		// the item is most likely an item coming after this item
 711		for {
 712			inx := l.firstSelectableItemBelow(l.selectedIndex)
 713			if inx == ItemNotFound {
 714				return nil
 715			}
 716			if inx >= len(l.itemPositions) {
 717				continue
 718			}
 719			renderedItem := l.itemPositions[inx]
 720
 721			// If the item is bigger than the viewport, select it
 722			if renderedItem.start <= start && renderedItem.end >= end {
 723				l.selectedIndex = inx
 724				return l.renderWithScrollToSelection(false)
 725			}
 726			// item is in the view
 727			if renderedItem.start >= start && renderedItem.start <= end {
 728				l.selectedIndex = inx
 729				return l.renderWithScrollToSelection(false)
 730			}
 731		}
 732	} else if itemMiddle > end {
 733		// select the first item in the viewport
 734		// the item is most likely an item coming after this item
 735		for {
 736			inx := l.firstSelectableItemAbove(l.selectedIndex)
 737			if inx == ItemNotFound {
 738				return nil
 739			}
 740			if inx >= len(l.itemPositions) {
 741				continue
 742			}
 743			renderedItem := l.itemPositions[inx]
 744
 745			// If the item is bigger than the viewport, select it
 746			if renderedItem.start <= start && renderedItem.end >= end {
 747				l.selectedIndex = inx
 748				return l.renderWithScrollToSelection(false)
 749			}
 750			// item is in the view
 751			if renderedItem.end >= start && renderedItem.end <= end {
 752				l.selectedIndex = inx
 753				return l.renderWithScrollToSelection(false)
 754			}
 755		}
 756	}
 757	return nil
 758}
 759
 760func (l *list[T]) selectFirstItem() {
 761	inx := l.firstSelectableItemBelow(-1)
 762	if inx != ItemNotFound {
 763		l.selectedIndex = inx
 764	}
 765}
 766
 767func (l *list[T]) selectLastItem() {
 768	inx := l.firstSelectableItemAbove(l.items.Len())
 769	if inx != ItemNotFound {
 770		l.selectedIndex = inx
 771	}
 772}
 773
 774func (l *list[T]) firstSelectableItemAbove(inx int) int {
 775	for i := inx - 1; i >= 0; i-- {
 776		item, ok := l.items.Get(i)
 777		if !ok {
 778			continue
 779		}
 780		if _, ok := any(item).(layout.Focusable); ok {
 781			return i
 782		}
 783	}
 784	if inx == 0 && l.wrap {
 785		return l.firstSelectableItemAbove(l.items.Len())
 786	}
 787	return ItemNotFound
 788}
 789
 790func (l *list[T]) firstSelectableItemBelow(inx int) int {
 791	itemsLen := l.items.Len()
 792	for i := inx + 1; i < itemsLen; i++ {
 793		item, ok := l.items.Get(i)
 794		if !ok {
 795			continue
 796		}
 797		if _, ok := any(item).(layout.Focusable); ok {
 798			return i
 799		}
 800	}
 801	if inx == itemsLen-1 && l.wrap {
 802		return l.firstSelectableItemBelow(-1)
 803	}
 804	return ItemNotFound
 805}
 806
 807func (l *list[T]) focusSelectedItem() tea.Cmd {
 808	if l.selectedIndex < 0 || !l.focused {
 809		return nil
 810	}
 811	var cmds []tea.Cmd
 812	for inx, item := range slices.Collect(l.items.Seq()) {
 813		if f, ok := any(item).(layout.Focusable); ok {
 814			if inx == l.selectedIndex && !f.IsFocused() {
 815				cmds = append(cmds, f.Focus())
 816				l.viewCache.Del(item.ID())
 817			} else if inx != l.selectedIndex && f.IsFocused() {
 818				cmds = append(cmds, f.Blur())
 819				l.viewCache.Del(item.ID())
 820			}
 821		}
 822	}
 823	return tea.Batch(cmds...)
 824}
 825
 826func (l *list[T]) blurSelectedItem() tea.Cmd {
 827	if l.selectedIndex < 0 || l.focused {
 828		return nil
 829	}
 830	var cmds []tea.Cmd
 831	for inx, item := range slices.Collect(l.items.Seq()) {
 832		if f, ok := any(item).(layout.Focusable); ok {
 833			if inx == l.selectedIndex && f.IsFocused() {
 834				cmds = append(cmds, f.Blur())
 835				l.viewCache.Del(item.ID())
 836			}
 837		}
 838	}
 839	return tea.Batch(cmds...)
 840}
 841
 842// calculateItemPositions calculates and caches the position and height of all items.
 843// This is O(n) but only called when the list structure changes significantly.
 844func (l *list[T]) calculateItemPositions() {
 845	itemsLen := l.items.Len()
 846
 847	// Resize positions slice if needed
 848	if len(l.itemPositions) != itemsLen {
 849		l.itemPositions = make([]itemPosition, itemsLen)
 850	}
 851
 852	currentHeight := 0
 853	// Always calculate positions in forward order (logical positions)
 854	for i := 0; i < itemsLen; i++ {
 855		item, ok := l.items.Get(i)
 856		if !ok {
 857			continue
 858		}
 859
 860		// Get cached view or render new one
 861		var view string
 862		if cached, ok := l.viewCache.Get(item.ID()); ok {
 863			view = cached
 864		} else {
 865			view = item.View()
 866			l.viewCache.Set(item.ID(), view)
 867		}
 868
 869		height := lipgloss.Height(view)
 870
 871		l.itemPositions[i] = itemPosition{
 872			height: height,
 873			start:  currentHeight,
 874			end:    currentHeight + height - 1,
 875		}
 876
 877		currentHeight += height
 878		if i < itemsLen-1 {
 879			currentHeight += l.gap
 880		}
 881	}
 882
 883	l.virtualHeight = currentHeight
 884}
 885
 886// updateItemPosition updates a single item's position and adjusts subsequent items.
 887// This is O(n) in worst case but only for items after the changed one.
 888func (l *list[T]) updateItemPosition(index int) {
 889	itemsLen := l.items.Len()
 890	if index < 0 || index >= itemsLen {
 891		return
 892	}
 893
 894	item, ok := l.items.Get(index)
 895	if !ok {
 896		return
 897	}
 898
 899	// Get new height
 900	view := item.View()
 901	l.viewCache.Set(item.ID(), view)
 902	newHeight := lipgloss.Height(view)
 903
 904	// If height hasn't changed, no need to update
 905	if index < len(l.itemPositions) && l.itemPositions[index].height == newHeight {
 906		return
 907	}
 908
 909	// Calculate starting position (from previous item or 0)
 910	var startPos int
 911	if index > 0 {
 912		startPos = l.itemPositions[index-1].end + 1 + l.gap
 913	}
 914
 915	// Update this item
 916	oldHeight := 0
 917	if index < len(l.itemPositions) {
 918		oldHeight = l.itemPositions[index].height
 919	}
 920	heightDiff := newHeight - oldHeight
 921
 922	l.itemPositions[index] = itemPosition{
 923		height: newHeight,
 924		start:  startPos,
 925		end:    startPos + newHeight - 1,
 926	}
 927
 928	// Update all subsequent items' positions (shift by heightDiff)
 929	for i := index + 1; i < len(l.itemPositions); i++ {
 930		l.itemPositions[i].start += heightDiff
 931		l.itemPositions[i].end += heightDiff
 932	}
 933
 934	// Update total height
 935	l.virtualHeight += heightDiff
 936}
 937
 938// renderVirtualScrolling renders only the visible portion of the list.
 939func (l *list[T]) renderVirtualScrolling() string {
 940	if l.items.Len() == 0 {
 941		return ""
 942	}
 943
 944	// Calculate viewport bounds
 945	viewStart, viewEnd := l.viewPosition()
 946
 947	// Check if we have any positions calculated
 948	if len(l.itemPositions) == 0 {
 949		// No positions calculated yet, return empty viewport
 950		return ""
 951	}
 952
 953	// Find which items are visible
 954	var visibleItems []struct {
 955		item  T
 956		pos   itemPosition
 957		index int
 958	}
 959
 960	itemsLen := l.items.Len()
 961	for i := 0; i < itemsLen; i++ {
 962		if i >= len(l.itemPositions) {
 963			continue
 964		}
 965
 966		pos := l.itemPositions[i]
 967
 968		// Check if item is visible (overlaps with viewport)
 969		if pos.end >= viewStart && pos.start <= viewEnd {
 970			item, ok := l.items.Get(i)
 971			if !ok {
 972				continue
 973			}
 974			visibleItems = append(visibleItems, struct {
 975				item  T
 976				pos   itemPosition
 977				index int
 978			}{item, pos, i})
 979		}
 980
 981		// Early exit if we've passed the viewport
 982		if pos.start > viewEnd {
 983			break
 984		}
 985	}
 986
 987	// Build the rendered output
 988	var lines []string
 989	currentLine := viewStart
 990
 991	for _, vis := range visibleItems {
 992		// Get or render the item's view
 993		var view string
 994		if cached, ok := l.viewCache.Get(vis.item.ID()); ok {
 995			view = cached
 996		} else {
 997			view = vis.item.View()
 998			l.viewCache.Set(vis.item.ID(), view)
 999		}
1000
1001		itemLines := strings.Split(view, "\n")
1002
1003		// Add gap lines before item if needed (except for first item)
1004		if vis.index > 0 && currentLine < vis.pos.start {
1005			gapLines := vis.pos.start - currentLine
1006			for i := 0; i < gapLines; i++ {
1007				lines = append(lines, "")
1008				currentLine++
1009			}
1010		}
1011
1012		// Determine which lines of this item to include
1013		startLine := 0
1014		if vis.pos.start < viewStart {
1015			// Item starts before viewport, skip some lines
1016			startLine = viewStart - vis.pos.start
1017		}
1018
1019		// Add the item's visible lines
1020		for i := startLine; i < len(itemLines) && currentLine <= viewEnd; i++ {
1021			lines = append(lines, itemLines[i])
1022			currentLine++
1023		}
1024	}
1025
1026	// For content that fits entirely in viewport, don't pad with empty lines
1027	// Only pad if we have scrolled or if content is larger than viewport
1028	if l.virtualHeight > l.height || l.offset > 0 {
1029		// Fill remaining viewport with empty lines if needed
1030		for len(lines) < l.height {
1031			lines = append(lines, "")
1032		}
1033
1034		// Trim to viewport height
1035		if len(lines) > l.height {
1036			lines = lines[:l.height]
1037		}
1038	}
1039
1040	return strings.Join(lines, "\n")
1041}
1042
1043// AppendItem implements List.
1044func (l *list[T]) AppendItem(item T) tea.Cmd {
1045	var cmds []tea.Cmd
1046	cmd := item.Init()
1047	if cmd != nil {
1048		cmds = append(cmds, cmd)
1049	}
1050
1051	l.items.Append(item)
1052	l.indexMap = csync.NewMap[string, int]()
1053	for inx, item := range slices.Collect(l.items.Seq()) {
1054		l.indexMap.Set(item.ID(), inx)
1055	}
1056
1057	l.shouldCalculateItemPositions = true
1058
1059	if l.width > 0 && l.height > 0 {
1060		cmd = item.SetSize(l.width, l.height)
1061		if cmd != nil {
1062			cmds = append(cmds, cmd)
1063		}
1064	}
1065	cmd = l.render()
1066	if cmd != nil {
1067		cmds = append(cmds, cmd)
1068	}
1069	if l.direction == DirectionBackward {
1070		if l.offset == 0 {
1071			cmd = l.GoToBottom()
1072			if cmd != nil {
1073				cmds = append(cmds, cmd)
1074			}
1075		}
1076		// Note: We can't adjust offset based on item height here since positions aren't calculated yet
1077	}
1078	return tea.Sequence(cmds...)
1079}
1080
1081// Blur implements List.
1082func (l *list[T]) Blur() tea.Cmd {
1083	l.focused = false
1084	return l.render()
1085}
1086
1087// DeleteItem implements List.
1088func (l *list[T]) DeleteItem(id string) tea.Cmd {
1089	inx, ok := l.indexMap.Get(id)
1090	if !ok {
1091		return nil
1092	}
1093	
1094	// Check if we're deleting the selected item
1095	if l.selectedIndex == inx {
1096		// Adjust selection
1097		if inx > 0 {
1098			l.selectedIndex = inx - 1
1099		} else if l.items.Len() > 1 {
1100			l.selectedIndex = 0 // Will be valid after deletion
1101		} else {
1102			l.selectedIndex = -1 // No items left
1103		}
1104	} else if l.selectedIndex > inx {
1105		// Adjust index if selected item is after deleted item
1106		l.selectedIndex--
1107	}
1108	
1109	l.items.Delete(inx)
1110	l.viewCache.Del(id)
1111	// Rebuild index map
1112	l.indexMap = csync.NewMap[string, int]()
1113	for inx, item := range slices.Collect(l.items.Seq()) {
1114		l.indexMap.Set(item.ID(), inx)
1115	}
1116	
1117	cmd := l.render()
1118	if l.rendered != "" {
1119		renderedHeight := l.virtualHeight
1120		if renderedHeight <= l.height {
1121			l.offset = 0
1122		} else {
1123			maxOffset := renderedHeight - l.height
1124			if l.offset > maxOffset {
1125				l.offset = maxOffset
1126			}
1127		}
1128	}
1129	return cmd
1130}
1131
1132// Focus implements List.
1133func (l *list[T]) Focus() tea.Cmd {
1134	l.focused = true
1135	return l.render()
1136}
1137
1138// GetSize implements List.
1139func (l *list[T]) GetSize() (int, int) {
1140	return l.width, l.height
1141}
1142
1143// GoToBottom implements List.
1144func (l *list[T]) GoToBottom() tea.Cmd {
1145	l.offset = 0
1146	l.selectedIndex = -1
1147	l.direction = DirectionBackward
1148	return l.render()
1149}
1150
1151// GoToTop implements List.
1152func (l *list[T]) GoToTop() tea.Cmd {
1153	l.offset = 0
1154	l.selectedIndex = -1
1155	l.direction = DirectionForward
1156	return l.render()
1157}
1158
1159// IsFocused implements List.
1160func (l *list[T]) IsFocused() bool {
1161	return l.focused
1162}
1163
1164// Items implements List.
1165func (l *list[T]) Items() []T {
1166	return slices.Collect(l.items.Seq())
1167}
1168
1169func (l *list[T]) incrementOffset(n int) {
1170	renderedHeight := l.virtualHeight
1171	// no need for offset
1172	if renderedHeight <= l.height {
1173		return
1174	}
1175	maxOffset := renderedHeight - l.height
1176	n = min(n, maxOffset-l.offset)
1177	if n <= 0 {
1178		return
1179	}
1180	l.offset += n
1181}
1182
1183func (l *list[T]) decrementOffset(n int) {
1184	n = min(n, l.offset)
1185	if n <= 0 {
1186		return
1187	}
1188	l.offset -= n
1189	if l.offset < 0 {
1190		l.offset = 0
1191	}
1192}
1193
1194// MoveDown implements List.
1195func (l *list[T]) MoveDown(n int) tea.Cmd {
1196	oldOffset := l.offset
1197	if l.direction == DirectionForward {
1198		l.incrementOffset(n)
1199	} else {
1200		l.decrementOffset(n)
1201	}
1202
1203	if oldOffset == l.offset {
1204		// Even if offset didn't change, we might need to change selection
1205		// if we're at the edge of the scrollable area
1206		return l.changeSelectionWhenScrolling()
1207	}
1208	// if we are not actively selecting move the whole selection down
1209	if l.hasSelection() && !l.selectionActive {
1210		if l.selectionStartLine < l.selectionEndLine {
1211			l.selectionStartLine -= n
1212			l.selectionEndLine -= n
1213		} else {
1214			l.selectionStartLine -= n
1215			l.selectionEndLine -= n
1216		}
1217	}
1218	if l.selectionActive {
1219		if l.selectionStartLine < l.selectionEndLine {
1220			l.selectionStartLine -= n
1221		} else {
1222			l.selectionEndLine -= n
1223		}
1224	}
1225	return l.changeSelectionWhenScrolling()
1226}
1227
1228// MoveUp implements List.
1229func (l *list[T]) MoveUp(n int) tea.Cmd {
1230	oldOffset := l.offset
1231	if l.direction == DirectionForward {
1232		l.decrementOffset(n)
1233	} else {
1234		l.incrementOffset(n)
1235	}
1236
1237	if oldOffset == l.offset {
1238		// Even if offset didn't change, we might need to change selection
1239		// if we're at the edge of the scrollable area
1240		return l.changeSelectionWhenScrolling()
1241	}
1242
1243	if l.hasSelection() && !l.selectionActive {
1244		if l.selectionStartLine > l.selectionEndLine {
1245			l.selectionStartLine += n
1246			l.selectionEndLine += n
1247		} else {
1248			l.selectionStartLine += n
1249			l.selectionEndLine += n
1250		}
1251	}
1252	if l.selectionActive {
1253		if l.selectionStartLine > l.selectionEndLine {
1254			l.selectionStartLine += n
1255		} else {
1256			l.selectionEndLine += n
1257		}
1258	}
1259	return l.changeSelectionWhenScrolling()
1260}
1261
1262// PrependItem implements List.
1263func (l *list[T]) PrependItem(item T) tea.Cmd {
1264	cmds := []tea.Cmd{
1265		item.Init(),
1266	}
1267	l.items.Prepend(item)
1268	l.indexMap = csync.NewMap[string, int]()
1269	for inx, item := range slices.Collect(l.items.Seq()) {
1270		l.indexMap.Set(item.ID(), inx)
1271	}
1272	if l.width > 0 && l.height > 0 {
1273		cmds = append(cmds, item.SetSize(l.width, l.height))
1274	}
1275
1276	l.shouldCalculateItemPositions = true
1277
1278	if l.direction == DirectionForward {
1279		if l.offset == 0 {
1280			// If we're at the top, stay at the top
1281			cmds = append(cmds, l.render())
1282			cmd := l.GoToTop()
1283			if cmd != nil {
1284				cmds = append(cmds, cmd)
1285			}
1286		} else {
1287			// Note: We need to calculate positions to adjust offset properly
1288			// This is one case where we might need to calculate immediately
1289			l.calculateItemPositions()
1290			l.shouldCalculateItemPositions = false
1291
1292			// Adjust offset to maintain viewport position
1293			// The prepended item is at index 0
1294			if len(l.itemPositions) > 0 {
1295				newItem := l.itemPositions[0]
1296				newLines := newItem.height
1297				if l.items.Len() > 1 {
1298					newLines += l.gap
1299				}
1300				// Increase offset to keep the same content visible
1301				if l.virtualHeight > 0 {
1302					l.offset = min(l.virtualHeight-l.height, l.offset+newLines)
1303				}
1304			}
1305			cmds = append(cmds, l.renderWithScrollToSelection(false))
1306		}
1307	} else {
1308		// For backward direction, prepending doesn't affect the offset
1309		// since offset is from the bottom
1310		cmds = append(cmds, l.render())
1311	}
1312	
1313	// Adjust selected index since we prepended
1314	if l.selectedIndex >= 0 {
1315		l.selectedIndex++
1316	}
1317	
1318	return tea.Batch(cmds...)
1319}
1320
1321// SelectItemAbove implements List.
1322func (l *list[T]) SelectItemAbove() tea.Cmd {
1323	if l.selectedIndex < 0 {
1324		return nil
1325	}
1326
1327	newIndex := l.firstSelectableItemAbove(l.selectedIndex)
1328	if newIndex == ItemNotFound {
1329		// no item above
1330		return nil
1331	}
1332	var cmds []tea.Cmd
1333	if newIndex == 1 {
1334		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1335		if peakAboveIndex == ItemNotFound {
1336			// this means there is a section above move to the top
1337			cmd := l.GoToTop()
1338			if cmd != nil {
1339				cmds = append(cmds, cmd)
1340			}
1341		}
1342	}
1343	l.selectedIndex = newIndex
1344	l.movingByItem = true
1345	renderCmd := l.render()
1346	if renderCmd != nil {
1347		cmds = append(cmds, renderCmd)
1348	}
1349	return tea.Sequence(cmds...)
1350}
1351
1352// SelectItemBelow implements List.
1353func (l *list[T]) SelectItemBelow() tea.Cmd {
1354	if l.selectedIndex < 0 {
1355		return nil
1356	}
1357
1358	newIndex := l.firstSelectableItemBelow(l.selectedIndex)
1359	if newIndex == ItemNotFound {
1360		// no item below
1361		return nil
1362	}
1363	l.selectedIndex = newIndex
1364	l.movingByItem = true
1365	return l.render()
1366}
1367
1368// SelectedItem implements List.
1369func (l *list[T]) SelectedItem() *T {
1370	if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1371		return nil
1372	}
1373	item, ok := l.items.Get(l.selectedIndex)
1374	if !ok {
1375		return nil
1376	}
1377	return &item
1378}
1379
1380// SelectedItemID returns the ID of the currently selected item (for testing).
1381func (l *list[T]) SelectedItemID() string {
1382	if l.selectedIndex < 0 || l.selectedIndex >= l.items.Len() {
1383		return ""
1384	}
1385	item, ok := l.items.Get(l.selectedIndex)
1386	if !ok {
1387		return ""
1388	}
1389	return item.ID()
1390}
1391
1392// SetItems implements List.
1393func (l *list[T]) SetItems(items []T) tea.Cmd {
1394	l.items.SetSlice(items)
1395	var cmds []tea.Cmd
1396	for inx, item := range slices.Collect(l.items.Seq()) {
1397		if i, ok := any(item).(Indexable); ok {
1398			i.SetIndex(inx)
1399		}
1400		cmds = append(cmds, item.Init())
1401	}
1402	cmds = append(cmds, l.reset(""))
1403	return tea.Batch(cmds...)
1404}
1405
1406// SetSelected implements List.
1407func (l *list[T]) SetSelected(id string) tea.Cmd {
1408	inx, ok := l.indexMap.Get(id)
1409	if ok {
1410		l.selectedIndex = inx
1411	} else {
1412		l.selectedIndex = -1
1413	}
1414	return l.render()
1415}
1416
1417func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1418	var cmds []tea.Cmd
1419	l.rendered = ""
1420	l.offset = 0
1421	
1422	// Convert ID to index if provided
1423	if selectedItemID != "" {
1424		if inx, ok := l.indexMap.Get(selectedItemID); ok {
1425			l.selectedIndex = inx
1426		} else {
1427			l.selectedIndex = -1
1428		}
1429	} else {
1430		l.selectedIndex = -1
1431	}
1432	
1433	l.indexMap = csync.NewMap[string, int]()
1434	l.viewCache = csync.NewMap[string, string]()
1435	l.itemPositions = nil // Will be recalculated
1436	l.virtualHeight = 0
1437	l.shouldCalculateItemPositions = true
1438	for inx, item := range slices.Collect(l.items.Seq()) {
1439		l.indexMap.Set(item.ID(), inx)
1440		if l.width > 0 && l.height > 0 {
1441			cmds = append(cmds, item.SetSize(l.width, l.height))
1442		}
1443	}
1444	cmds = append(cmds, l.render())
1445	return tea.Batch(cmds...)
1446}
1447
1448// SetSize implements List.
1449func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1450	oldWidth := l.width
1451	l.width = width
1452	l.height = height
1453	if oldWidth != width {
1454		// Get current selected item ID to preserve selection
1455		var selectedID string
1456		if l.selectedIndex >= 0 && l.selectedIndex < l.items.Len() {
1457			if item, ok := l.items.Get(l.selectedIndex); ok {
1458				selectedID = item.ID()
1459			}
1460		}
1461		cmd := l.reset(selectedID)
1462		return cmd
1463	}
1464	return nil
1465}
1466
1467// UpdateItem implements List.
1468func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1469	var cmds []tea.Cmd
1470	if inx, ok := l.indexMap.Get(id); ok {
1471		// Update the item
1472		l.items.Set(inx, item)
1473
1474		// Clear cache for this item
1475		l.viewCache.Del(id)
1476
1477		// Mark positions as dirty for recalculation
1478		l.shouldCalculateItemPositions = true
1479
1480		// Re-render with updated positions
1481		cmd := l.renderWithScrollToSelection(false)
1482		if cmd != nil {
1483			cmds = append(cmds, cmd)
1484		}
1485
1486		cmds = append(cmds, item.Init())
1487		if l.width > 0 && l.height > 0 {
1488			cmds = append(cmds, item.SetSize(l.width, l.height))
1489		}
1490	}
1491	return tea.Sequence(cmds...)
1492}
1493
1494func (l *list[T]) hasSelection() bool {
1495	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1496}
1497
1498// StartSelection implements List.
1499func (l *list[T]) StartSelection(col, line int) {
1500	l.selectionStartCol = col
1501	l.selectionStartLine = line
1502	l.selectionEndCol = col
1503	l.selectionEndLine = line
1504	l.selectionActive = true
1505}
1506
1507// EndSelection implements List.
1508func (l *list[T]) EndSelection(col, line int) {
1509	if !l.selectionActive {
1510		return
1511	}
1512	l.selectionEndCol = col
1513	l.selectionEndLine = line
1514}
1515
1516func (l *list[T]) SelectionStop() {
1517	l.selectionActive = false
1518}
1519
1520func (l *list[T]) SelectionClear() {
1521	l.selectionStartCol = -1
1522	l.selectionStartLine = -1
1523	l.selectionEndCol = -1
1524	l.selectionEndLine = -1
1525	l.selectionActive = false
1526}
1527
1528func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1529	lines := strings.Split(l.rendered, "\n")
1530	for i, l := range lines {
1531		lines[i] = ansi.Strip(l)
1532	}
1533
1534	if l.direction == DirectionBackward && len(lines) > l.height {
1535		line = ((len(lines) - 1) - l.height) + line + 1
1536	}
1537
1538	if l.offset > 0 {
1539		if l.direction == DirectionBackward {
1540			line -= l.offset
1541		} else {
1542			line += l.offset
1543		}
1544	}
1545
1546	if line < 0 || line >= len(lines) {
1547		return 0, 0
1548	}
1549
1550	currentLine := lines[line]
1551	gr := uniseg.NewGraphemes(currentLine)
1552	startCol = -1
1553	upTo := col
1554	for gr.Next() {
1555		if gr.IsWordBoundary() && upTo > 0 {
1556			startCol = col - upTo + 1
1557		} else if gr.IsWordBoundary() && upTo < 0 {
1558			endCol = col - upTo + 1
1559			break
1560		}
1561		if upTo == 0 && gr.Str() == " " {
1562			return 0, 0
1563		}
1564		upTo -= 1
1565	}
1566	if startCol == -1 {
1567		return 0, 0
1568	}
1569	return
1570}
1571
1572func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1573	lines := strings.Split(l.rendered, "\n")
1574	for i, l := range lines {
1575		lines[i] = ansi.Strip(l)
1576		for _, icon := range styles.SelectionIgnoreIcons {
1577			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1578		}
1579	}
1580	if l.direction == DirectionBackward && len(lines) > l.height {
1581		line = (len(lines) - 1) - l.height + line + 1
1582	}
1583
1584	if l.offset > 0 {
1585		if l.direction == DirectionBackward {
1586			line -= l.offset
1587		} else {
1588			line += l.offset
1589		}
1590	}
1591
1592	// Ensure line is within bounds
1593	if line < 0 || line >= len(lines) {
1594		return 0, 0, false
1595	}
1596
1597	if strings.TrimSpace(lines[line]) == "" {
1598		return 0, 0, false
1599	}
1600
1601	// Find start of paragraph (search backwards for empty line or start of text)
1602	startLine = line
1603	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1604		startLine--
1605	}
1606
1607	// Find end of paragraph (search forwards for empty line or end of text)
1608	endLine = line
1609	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1610		endLine++
1611	}
1612
1613	// revert the line numbers if we are in backward direction
1614	if l.direction == DirectionBackward && len(lines) > l.height {
1615		startLine = startLine - (len(lines) - 1) + l.height - 1
1616		endLine = endLine - (len(lines) - 1) + l.height - 1
1617	}
1618	if l.offset > 0 {
1619		if l.direction == DirectionBackward {
1620			startLine += l.offset
1621			endLine += l.offset
1622		} else {
1623			startLine -= l.offset
1624			endLine -= l.offset
1625		}
1626	}
1627	return startLine, endLine, true
1628}
1629
1630// SelectWord selects the word at the given position.
1631func (l *list[T]) SelectWord(col, line int) {
1632	startCol, endCol := l.findWordBoundaries(col, line)
1633	l.selectionStartCol = startCol
1634	l.selectionStartLine = line
1635	l.selectionEndCol = endCol
1636	l.selectionEndLine = line
1637	l.selectionActive = false // Not actively selecting, just selected
1638}
1639
1640// SelectParagraph selects the paragraph at the given position.
1641func (l *list[T]) SelectParagraph(col, line int) {
1642	startLine, endLine, found := l.findParagraphBoundaries(line)
1643	if !found {
1644		return
1645	}
1646	l.selectionStartCol = 0
1647	l.selectionStartLine = startLine
1648	l.selectionEndCol = l.width - 1
1649	l.selectionEndLine = endLine
1650	l.selectionActive = false // Not actively selecting, just selected
1651}
1652
1653// HasSelection returns whether there is an active selection.
1654func (l *list[T]) HasSelection() bool {
1655	return l.hasSelection()
1656}
1657
1658// GetSelectedText returns the currently selected text.
1659func (l *list[T]) GetSelectedText(paddingLeft int) string {
1660	if !l.hasSelection() {
1661		return ""
1662	}
1663
1664	return l.selectionView(l.View(), true)
1665}