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	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.
 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		shouldCalculateItemPositions: true,
 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.SelectItemBelow()
 312			case key.Matches(msg, l.keyMap.Up):
 313				return l, l.SelectItemAbove()
 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	if l.shouldCalculateItemPositions {
 577		l.calculateItemPositions()
 578		l.shouldCalculateItemPositions = false
 579	}
 580
 581	// Scroll to selected item BEFORE rendering if focused and requested
 582	if l.focused && scrollToSelection {
 583		l.scrollToSelection()
 584	}
 585
 586	// Render only visible items
 587	l.renderMu.Lock()
 588	l.rendered = l.renderVirtualScrolling()
 589	l.renderMu.Unlock()
 590
 591	return focusChangeCmd
 592}
 593
 594func (l *list[T]) setDefaultSelected() {
 595	if l.selectedItem == "" {
 596		if l.direction == DirectionForward {
 597			l.selectFirstItem()
 598		} else {
 599			l.selectLastItem()
 600		}
 601	}
 602}
 603
 604func (l *list[T]) scrollToSelection() {
 605	if l.selectedItem == "" {
 606		return
 607	}
 608
 609	inx, ok := l.indexMap.Get(l.selectedItem)
 610	if !ok || inx < 0 || inx >= len(l.itemPositions) {
 611		l.selectedItem = ""
 612		l.setDefaultSelected()
 613		return
 614	}
 615
 616	rItem := l.itemPositions[inx]
 617
 618	start, end := l.viewPosition()
 619
 620	// item bigger or equal to the viewport - show from start
 621	if rItem.height >= l.height {
 622		if l.direction == DirectionForward {
 623			l.offset = rItem.start
 624		} else {
 625			// For backward direction, we want to show the bottom of the item
 626			// offset = 0 means bottom of list is visible
 627			l.offset = 0
 628		}
 629		return
 630	}
 631
 632	// if we are moving by item we want to move the offset so that the
 633	// whole item is visible not just portions of it
 634	if l.movingByItem {
 635		if rItem.start >= start && rItem.end <= end {
 636			// Item is fully visible, no need to scroll
 637			return
 638		}
 639		defer func() { l.movingByItem = false }()
 640	} else {
 641		// item already in view do nothing
 642		if rItem.start >= start && rItem.start <= end {
 643			return
 644		}
 645		if rItem.end >= start && rItem.end <= end {
 646			return
 647		}
 648	}
 649
 650	// If item is above the viewport, make it the first item
 651	if rItem.start < start {
 652		if l.direction == DirectionForward {
 653			l.offset = rItem.start
 654		} else {
 655			if l.virtualHeight > 0 {
 656				l.offset = l.virtualHeight - rItem.end
 657			} else {
 658				l.offset = 0
 659			}
 660		}
 661	} else if rItem.end > end {
 662		// If item is below the viewport, make it the last item
 663		if l.direction == DirectionForward {
 664			l.offset = max(0, rItem.end-l.height+1)
 665		} else {
 666			if l.virtualHeight > 0 {
 667				l.offset = max(0, l.virtualHeight-rItem.start-l.height+1)
 668			} else {
 669				l.offset = 0
 670			}
 671		}
 672	}
 673}
 674
 675func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 676	inx, ok := l.indexMap.Get(l.selectedItem)
 677	if !ok || inx < 0 || inx >= len(l.itemPositions) {
 678		return nil
 679	}
 680
 681	rItem := l.itemPositions[inx]
 682	start, end := l.viewPosition()
 683	// item bigger than the viewport do nothing
 684	if rItem.start <= start && rItem.end >= end {
 685		return nil
 686	}
 687	// item already in view do nothing
 688	if rItem.start >= start && rItem.end <= end {
 689		return nil
 690	}
 691
 692	itemMiddle := rItem.start + rItem.height/2
 693
 694	if itemMiddle < start {
 695		// select the first item in the viewport
 696		// the item is most likely an item coming after this item
 697		for {
 698			inx = l.firstSelectableItemBelow(inx)
 699			if inx == ItemNotFound {
 700				return nil
 701			}
 702			item, ok := l.items.Get(inx)
 703			if !ok {
 704				continue
 705			}
 706			if inx >= len(l.itemPositions) {
 707				continue
 708			}
 709			renderedItem := l.itemPositions[inx]
 710
 711			// If the item is bigger than the viewport, select it
 712			if renderedItem.start <= start && renderedItem.end >= end {
 713				l.selectedItem = item.ID()
 714				return l.renderWithScrollToSelection(false)
 715			}
 716			// item is in the view
 717			if renderedItem.start >= start && renderedItem.start <= end {
 718				l.selectedItem = item.ID()
 719				return l.renderWithScrollToSelection(false)
 720			}
 721		}
 722	} else if itemMiddle > end {
 723		// select the first item in the viewport
 724		// the item is most likely an item coming after this item
 725		for {
 726			inx = l.firstSelectableItemAbove(inx)
 727			if inx == ItemNotFound {
 728				return nil
 729			}
 730			item, ok := l.items.Get(inx)
 731			if !ok {
 732				continue
 733			}
 734			if inx >= len(l.itemPositions) {
 735				continue
 736			}
 737			renderedItem := l.itemPositions[inx]
 738
 739			// If the item is bigger than the viewport, select it
 740			if renderedItem.start <= start && renderedItem.end >= end {
 741				l.selectedItem = item.ID()
 742				return l.renderWithScrollToSelection(false)
 743			}
 744			// item is in the view
 745			if renderedItem.end >= start && renderedItem.end <= end {
 746				l.selectedItem = item.ID()
 747				return l.renderWithScrollToSelection(false)
 748			}
 749		}
 750	}
 751	return nil
 752}
 753
 754func (l *list[T]) selectFirstItem() {
 755	inx := l.firstSelectableItemBelow(-1)
 756	if inx != ItemNotFound {
 757		item, ok := l.items.Get(inx)
 758		if ok {
 759			l.selectedItem = item.ID()
 760		}
 761	}
 762}
 763
 764func (l *list[T]) selectLastItem() {
 765	inx := l.firstSelectableItemAbove(l.items.Len())
 766	if inx != ItemNotFound {
 767		item, ok := l.items.Get(inx)
 768		if ok {
 769			l.selectedItem = item.ID()
 770		}
 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.selectedItem == "" || !l.focused {
 809		return nil
 810	}
 811	var cmds []tea.Cmd
 812	for _, item := range slices.Collect(l.items.Seq()) {
 813		if f, ok := any(item).(layout.Focusable); ok {
 814			if item.ID() == l.selectedItem && !f.IsFocused() {
 815				cmds = append(cmds, f.Focus())
 816				l.viewCache.Del(item.ID())
 817			} else if item.ID() != l.selectedItem && 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.selectedItem == "" || l.focused {
 828		return nil
 829	}
 830	var cmds []tea.Cmd
 831	for _, item := range slices.Collect(l.items.Seq()) {
 832		if f, ok := any(item).(layout.Focusable); ok {
 833			if item.ID() == l.selectedItem && 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	l.items.Delete(inx)
1094	l.viewCache.Del(id)
1095	// Rebuild index map
1096	l.indexMap = csync.NewMap[string, int]()
1097	for inx, item := range slices.Collect(l.items.Seq()) {
1098		l.indexMap.Set(item.ID(), inx)
1099	}
1100
1101	if l.selectedItem == id {
1102		if inx > 0 {
1103			item, ok := l.items.Get(inx - 1)
1104			if ok {
1105				l.selectedItem = item.ID()
1106			} else {
1107				l.selectedItem = ""
1108			}
1109		} else {
1110			l.selectedItem = ""
1111		}
1112	}
1113	cmd := l.render()
1114	if l.rendered != "" {
1115		renderedHeight := l.virtualHeight
1116		if renderedHeight <= l.height {
1117			l.offset = 0
1118		} else {
1119			maxOffset := renderedHeight - l.height
1120			if l.offset > maxOffset {
1121				l.offset = maxOffset
1122			}
1123		}
1124	}
1125	return cmd
1126}
1127
1128// Focus implements List.
1129func (l *list[T]) Focus() tea.Cmd {
1130	l.focused = true
1131	return l.render()
1132}
1133
1134// GetSize implements List.
1135func (l *list[T]) GetSize() (int, int) {
1136	return l.width, l.height
1137}
1138
1139// GoToBottom implements List.
1140func (l *list[T]) GoToBottom() tea.Cmd {
1141	l.offset = 0
1142	l.selectedItem = ""
1143	l.direction = DirectionBackward
1144	return l.render()
1145}
1146
1147// GoToTop implements List.
1148func (l *list[T]) GoToTop() tea.Cmd {
1149	l.offset = 0
1150	l.selectedItem = ""
1151	l.direction = DirectionForward
1152	return l.render()
1153}
1154
1155// IsFocused implements List.
1156func (l *list[T]) IsFocused() bool {
1157	return l.focused
1158}
1159
1160// Items implements List.
1161func (l *list[T]) Items() []T {
1162	return slices.Collect(l.items.Seq())
1163}
1164
1165func (l *list[T]) incrementOffset(n int) {
1166	renderedHeight := l.virtualHeight
1167	// no need for offset
1168	if renderedHeight <= l.height {
1169		return
1170	}
1171	maxOffset := renderedHeight - l.height
1172	n = min(n, maxOffset-l.offset)
1173	if n <= 0 {
1174		return
1175	}
1176	l.offset += n
1177}
1178
1179func (l *list[T]) decrementOffset(n int) {
1180	n = min(n, l.offset)
1181	if n <= 0 {
1182		return
1183	}
1184	l.offset -= n
1185	if l.offset < 0 {
1186		l.offset = 0
1187	}
1188}
1189
1190// MoveDown implements List.
1191func (l *list[T]) MoveDown(n int) tea.Cmd {
1192	oldOffset := l.offset
1193	if l.direction == DirectionForward {
1194		l.incrementOffset(n)
1195	} else {
1196		l.decrementOffset(n)
1197	}
1198
1199	if oldOffset == l.offset {
1200		// Even if offset didn't change, we might need to change selection
1201		// if we're at the edge of the scrollable area
1202		return l.changeSelectionWhenScrolling()
1203	}
1204	// if we are not actively selecting move the whole selection down
1205	if l.hasSelection() && !l.selectionActive {
1206		if l.selectionStartLine < l.selectionEndLine {
1207			l.selectionStartLine -= n
1208			l.selectionEndLine -= n
1209		} else {
1210			l.selectionStartLine -= n
1211			l.selectionEndLine -= n
1212		}
1213	}
1214	if l.selectionActive {
1215		if l.selectionStartLine < l.selectionEndLine {
1216			l.selectionStartLine -= n
1217		} else {
1218			l.selectionEndLine -= n
1219		}
1220	}
1221	return l.changeSelectionWhenScrolling()
1222}
1223
1224// MoveUp implements List.
1225func (l *list[T]) MoveUp(n int) tea.Cmd {
1226	oldOffset := l.offset
1227	if l.direction == DirectionForward {
1228		l.decrementOffset(n)
1229	} else {
1230		l.incrementOffset(n)
1231	}
1232
1233	if oldOffset == l.offset {
1234		// Even if offset didn't change, we might need to change selection
1235		// if we're at the edge of the scrollable area
1236		return l.changeSelectionWhenScrolling()
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	l.shouldCalculateItemPositions = true
1273
1274	if l.direction == DirectionForward {
1275		if l.offset == 0 {
1276			// If we're at the top, stay at the top
1277			cmds = append(cmds, l.render())
1278			cmd := l.GoToTop()
1279			if cmd != nil {
1280				cmds = append(cmds, cmd)
1281			}
1282		} else {
1283			// Note: We need to calculate positions to adjust offset properly
1284			// This is one case where we might need to calculate immediately
1285			l.calculateItemPositions()
1286			l.shouldCalculateItemPositions = false
1287
1288			// Adjust offset to maintain viewport position
1289			// The prepended item is at index 0
1290			if len(l.itemPositions) > 0 {
1291				newItem := l.itemPositions[0]
1292				newLines := newItem.height
1293				if l.items.Len() > 1 {
1294					newLines += l.gap
1295				}
1296				// Increase offset to keep the same content visible
1297				if l.virtualHeight > 0 {
1298					l.offset = min(l.virtualHeight-l.height, l.offset+newLines)
1299				}
1300			}
1301			cmds = append(cmds, l.renderWithScrollToSelection(false))
1302		}
1303	} else {
1304		// For backward direction, prepending doesn't affect the offset
1305		// since offset is from the bottom
1306		cmds = append(cmds, l.render())
1307	}
1308	return tea.Batch(cmds...)
1309}
1310
1311// SelectItemAbove implements List.
1312func (l *list[T]) SelectItemAbove() tea.Cmd {
1313	inx, ok := l.indexMap.Get(l.selectedItem)
1314	if !ok {
1315		return nil
1316	}
1317
1318	newIndex := l.firstSelectableItemAbove(inx)
1319	if newIndex == ItemNotFound {
1320		// no item above
1321		return nil
1322	}
1323	var cmds []tea.Cmd
1324	if newIndex == 1 {
1325		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1326		if peakAboveIndex == ItemNotFound {
1327			// this means there is a section above move to the top
1328			cmd := l.GoToTop()
1329			if cmd != nil {
1330				cmds = append(cmds, cmd)
1331			}
1332		}
1333	}
1334	item, ok := l.items.Get(newIndex)
1335	if !ok {
1336		return nil
1337	}
1338	l.selectedItem = item.ID()
1339	l.movingByItem = true
1340	renderCmd := l.render()
1341	if renderCmd != nil {
1342		cmds = append(cmds, renderCmd)
1343	}
1344	return tea.Sequence(cmds...)
1345}
1346
1347// SelectItemBelow implements List.
1348func (l *list[T]) SelectItemBelow() tea.Cmd {
1349	inx, ok := l.indexMap.Get(l.selectedItem)
1350	if !ok {
1351		return nil
1352	}
1353
1354	newIndex := l.firstSelectableItemBelow(inx)
1355	if newIndex == ItemNotFound {
1356		// no item above
1357		return nil
1358	}
1359	item, ok := l.items.Get(newIndex)
1360	if !ok {
1361		return nil
1362	}
1363	l.selectedItem = item.ID()
1364	l.movingByItem = true
1365	return l.render()
1366}
1367
1368// SelectedItem implements List.
1369func (l *list[T]) SelectedItem() *T {
1370	inx, ok := l.indexMap.Get(l.selectedItem)
1371	if !ok {
1372		return nil
1373	}
1374	if inx > l.items.Len()-1 {
1375		return nil
1376	}
1377	item, ok := l.items.Get(inx)
1378	if !ok {
1379		return nil
1380	}
1381	return &item
1382}
1383
1384// SetItems implements List.
1385func (l *list[T]) SetItems(items []T) tea.Cmd {
1386	l.items.SetSlice(items)
1387	var cmds []tea.Cmd
1388	for inx, item := range slices.Collect(l.items.Seq()) {
1389		if i, ok := any(item).(Indexable); ok {
1390			i.SetIndex(inx)
1391		}
1392		cmds = append(cmds, item.Init())
1393	}
1394	cmds = append(cmds, l.reset(""))
1395	return tea.Batch(cmds...)
1396}
1397
1398// SetSelected implements List.
1399func (l *list[T]) SetSelected(id string) tea.Cmd {
1400	l.selectedItem = id
1401	return l.render()
1402}
1403
1404func (l *list[T]) reset(selectedItem string) tea.Cmd {
1405	var cmds []tea.Cmd
1406	l.rendered = ""
1407	l.offset = 0
1408	l.selectedItem = selectedItem
1409	l.indexMap = csync.NewMap[string, int]()
1410	l.viewCache = csync.NewMap[string, string]()
1411	l.itemPositions = nil // Will be recalculated
1412	l.virtualHeight = 0
1413	l.shouldCalculateItemPositions = true
1414	for inx, item := range slices.Collect(l.items.Seq()) {
1415		l.indexMap.Set(item.ID(), inx)
1416		if l.width > 0 && l.height > 0 {
1417			cmds = append(cmds, item.SetSize(l.width, l.height))
1418		}
1419	}
1420	cmds = append(cmds, l.render())
1421	return tea.Batch(cmds...)
1422}
1423
1424// SetSize implements List.
1425func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1426	oldWidth := l.width
1427	l.width = width
1428	l.height = height
1429	if oldWidth != width {
1430		cmd := l.reset(l.selectedItem)
1431		return cmd
1432	}
1433	return nil
1434}
1435
1436// UpdateItem implements List.
1437func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1438	var cmds []tea.Cmd
1439	if inx, ok := l.indexMap.Get(id); ok {
1440		// Update the item
1441		l.items.Set(inx, item)
1442
1443		// Clear cache for this item
1444		l.viewCache.Del(id)
1445
1446		// Mark positions as dirty for recalculation
1447		l.shouldCalculateItemPositions = true
1448
1449		// Re-render with updated positions
1450		cmd := l.renderWithScrollToSelection(false)
1451		if cmd != nil {
1452			cmds = append(cmds, cmd)
1453		}
1454
1455		cmds = append(cmds, item.Init())
1456		if l.width > 0 && l.height > 0 {
1457			cmds = append(cmds, item.SetSize(l.width, l.height))
1458		}
1459	}
1460	return tea.Sequence(cmds...)
1461}
1462
1463func (l *list[T]) hasSelection() bool {
1464	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1465}
1466
1467// StartSelection implements List.
1468func (l *list[T]) StartSelection(col, line int) {
1469	l.selectionStartCol = col
1470	l.selectionStartLine = line
1471	l.selectionEndCol = col
1472	l.selectionEndLine = line
1473	l.selectionActive = true
1474}
1475
1476// EndSelection implements List.
1477func (l *list[T]) EndSelection(col, line int) {
1478	if !l.selectionActive {
1479		return
1480	}
1481	l.selectionEndCol = col
1482	l.selectionEndLine = line
1483}
1484
1485func (l *list[T]) SelectionStop() {
1486	l.selectionActive = false
1487}
1488
1489func (l *list[T]) SelectionClear() {
1490	l.selectionStartCol = -1
1491	l.selectionStartLine = -1
1492	l.selectionEndCol = -1
1493	l.selectionEndLine = -1
1494	l.selectionActive = false
1495}
1496
1497func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1498	lines := strings.Split(l.rendered, "\n")
1499	for i, l := range lines {
1500		lines[i] = ansi.Strip(l)
1501	}
1502
1503	if l.direction == DirectionBackward && len(lines) > l.height {
1504		line = ((len(lines) - 1) - l.height) + line + 1
1505	}
1506
1507	if l.offset > 0 {
1508		if l.direction == DirectionBackward {
1509			line -= l.offset
1510		} else {
1511			line += l.offset
1512		}
1513	}
1514
1515	if line < 0 || line >= len(lines) {
1516		return 0, 0
1517	}
1518
1519	currentLine := lines[line]
1520	gr := uniseg.NewGraphemes(currentLine)
1521	startCol = -1
1522	upTo := col
1523	for gr.Next() {
1524		if gr.IsWordBoundary() && upTo > 0 {
1525			startCol = col - upTo + 1
1526		} else if gr.IsWordBoundary() && upTo < 0 {
1527			endCol = col - upTo + 1
1528			break
1529		}
1530		if upTo == 0 && gr.Str() == " " {
1531			return 0, 0
1532		}
1533		upTo -= 1
1534	}
1535	if startCol == -1 {
1536		return 0, 0
1537	}
1538	return
1539}
1540
1541func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1542	lines := strings.Split(l.rendered, "\n")
1543	for i, l := range lines {
1544		lines[i] = ansi.Strip(l)
1545		for _, icon := range styles.SelectionIgnoreIcons {
1546			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1547		}
1548	}
1549	if l.direction == DirectionBackward && len(lines) > l.height {
1550		line = (len(lines) - 1) - l.height + line + 1
1551	}
1552
1553	if l.offset > 0 {
1554		if l.direction == DirectionBackward {
1555			line -= l.offset
1556		} else {
1557			line += l.offset
1558		}
1559	}
1560
1561	// Ensure line is within bounds
1562	if line < 0 || line >= len(lines) {
1563		return 0, 0, false
1564	}
1565
1566	if strings.TrimSpace(lines[line]) == "" {
1567		return 0, 0, false
1568	}
1569
1570	// Find start of paragraph (search backwards for empty line or start of text)
1571	startLine = line
1572	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1573		startLine--
1574	}
1575
1576	// Find end of paragraph (search forwards for empty line or end of text)
1577	endLine = line
1578	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1579		endLine++
1580	}
1581
1582	// revert the line numbers if we are in backward direction
1583	if l.direction == DirectionBackward && len(lines) > l.height {
1584		startLine = startLine - (len(lines) - 1) + l.height - 1
1585		endLine = endLine - (len(lines) - 1) + l.height - 1
1586	}
1587	if l.offset > 0 {
1588		if l.direction == DirectionBackward {
1589			startLine += l.offset
1590			endLine += l.offset
1591		} else {
1592			startLine -= l.offset
1593			endLine -= l.offset
1594		}
1595	}
1596	return startLine, endLine, true
1597}
1598
1599// SelectWord selects the word at the given position.
1600func (l *list[T]) SelectWord(col, line int) {
1601	startCol, endCol := l.findWordBoundaries(col, line)
1602	l.selectionStartCol = startCol
1603	l.selectionStartLine = line
1604	l.selectionEndCol = endCol
1605	l.selectionEndLine = line
1606	l.selectionActive = false // Not actively selecting, just selected
1607}
1608
1609// SelectParagraph selects the paragraph at the given position.
1610func (l *list[T]) SelectParagraph(col, line int) {
1611	startLine, endLine, found := l.findParagraphBoundaries(line)
1612	if !found {
1613		return
1614	}
1615	l.selectionStartCol = 0
1616	l.selectionStartLine = startLine
1617	l.selectionEndCol = l.width - 1
1618	l.selectionEndLine = endLine
1619	l.selectionActive = false // Not actively selecting, just selected
1620}
1621
1622// HasSelection returns whether there is an active selection.
1623func (l *list[T]) HasSelection() bool {
1624	return l.hasSelection()
1625}
1626
1627// GetSelectedText returns the currently selected text.
1628func (l *list[T]) GetSelectedText(paddingLeft int) string {
1629	if !l.hasSelection() {
1630		return ""
1631	}
1632
1633	return l.selectionView(l.View(), true)
1634}