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