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