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
 106	renderMu sync.Mutex
 107	rendered string
 108
 109	movingByItem       bool
 110	selectionStartCol  int
 111	selectionStartLine int
 112	selectionEndCol    int
 113	selectionEndLine   int
 114
 115	selectionActive bool
 116}
 117
 118type ListOption func(*confOptions)
 119
 120// WithSize sets the size of the list.
 121func WithSize(width, height int) ListOption {
 122	return func(l *confOptions) {
 123		l.width = width
 124		l.height = height
 125	}
 126}
 127
 128// WithGap sets the gap between items in the list.
 129func WithGap(gap int) ListOption {
 130	return func(l *confOptions) {
 131		l.gap = gap
 132	}
 133}
 134
 135// WithDirectionForward sets the direction to forward
 136func WithDirectionForward() ListOption {
 137	return func(l *confOptions) {
 138		l.direction = DirectionForward
 139	}
 140}
 141
 142// WithDirectionBackward sets the direction to forward
 143func WithDirectionBackward() ListOption {
 144	return func(l *confOptions) {
 145		l.direction = DirectionBackward
 146	}
 147}
 148
 149// WithSelectedItem sets the initially selected item in the list.
 150func WithSelectedItem(id string) ListOption {
 151	return func(l *confOptions) {
 152		l.selectedItem = id
 153	}
 154}
 155
 156func WithKeyMap(keyMap KeyMap) ListOption {
 157	return func(l *confOptions) {
 158		l.keyMap = keyMap
 159	}
 160}
 161
 162func WithWrapNavigation() ListOption {
 163	return func(l *confOptions) {
 164		l.wrap = true
 165	}
 166}
 167
 168func WithFocus(focus bool) ListOption {
 169	return func(l *confOptions) {
 170		l.focused = focus
 171	}
 172}
 173
 174func WithResizeByList() ListOption {
 175	return func(l *confOptions) {
 176		l.resize = true
 177	}
 178}
 179
 180func WithEnableMouse() ListOption {
 181	return func(l *confOptions) {
 182		l.enableMouse = true
 183	}
 184}
 185
 186func New[T Item](items []T, opts ...ListOption) List[T] {
 187	list := &list[T]{
 188		confOptions: &confOptions{
 189			direction: DirectionForward,
 190			keyMap:    DefaultKeyMap(),
 191			focused:   true,
 192		},
 193		items:              csync.NewSliceFrom(items),
 194		indexMap:           csync.NewMap[string, int](),
 195		itemPositions:      make([]itemPosition, len(items)),
 196		viewCache:          csync.NewMap[string, string](),
 197		selectionStartCol:  -1,
 198		selectionStartLine: -1,
 199		selectionEndLine:   -1,
 200		selectionEndCol:    -1,
 201	}
 202	for _, opt := range opts {
 203		opt(list.confOptions)
 204	}
 205
 206	for inx, item := range items {
 207		if i, ok := any(item).(Indexable); ok {
 208			i.SetIndex(inx)
 209		}
 210		list.indexMap.Set(item.ID(), inx)
 211	}
 212	return list
 213}
 214
 215// Init implements List.
 216func (l *list[T]) Init() tea.Cmd {
 217	// Ensure we have width and height
 218	if l.width <= 0 || l.height <= 0 {
 219		// Can't calculate positions without dimensions
 220		return nil
 221	}
 222	
 223	// Set size for all items
 224	var cmds []tea.Cmd
 225	for _, item := range slices.Collect(l.items.Seq()) {
 226		if cmd := item.SetSize(l.width, l.height); cmd != nil {
 227			cmds = append(cmds, cmd)
 228		}
 229	}
 230	
 231	// Calculate positions for all items
 232	l.calculateItemPositions()
 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		// Set offset to show the bottom of the list
 237		if l.virtualHeight > l.height {
 238			l.offset = 0 // In backward mode, offset 0 means bottom
 239		}
 240		// Select the last item if no item is selected
 241		if l.selectedItem == "" {
 242			l.selectLastItem()
 243		}
 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	if l.selectedItem == "" {
 572		return
 573	}
 574	
 575	inx, ok := l.indexMap.Get(l.selectedItem)
 576	if !ok || inx < 0 || inx >= len(l.itemPositions) {
 577		l.selectedItem = ""
 578		l.setDefaultSelected()
 579		return
 580	}
 581	
 582	rItem := l.itemPositions[inx]
 583
 584	start, end := l.viewPosition()
 585	
 586	// item bigger or equal to the viewport - show from start
 587	if rItem.height >= l.height {
 588		if l.direction == DirectionForward {
 589			l.offset = rItem.start
 590		} else {
 591			// For backward direction, we want to show the bottom of the item
 592			// offset = 0 means bottom of list is visible
 593			l.offset = 0
 594		}
 595		return
 596	}
 597	
 598	// if we are moving by item we want to move the offset so that the
 599	// whole item is visible not just portions of it
 600	if l.movingByItem {
 601		if rItem.start >= start && rItem.end <= end {
 602			// Item is fully visible, no need to scroll
 603			return
 604		}
 605		defer func() { l.movingByItem = false }()
 606	} else {
 607		// item already in view do nothing
 608		if rItem.start >= start && rItem.start <= end {
 609			return
 610		}
 611		if rItem.end >= start && rItem.end <= end {
 612			return
 613		}
 614	}
 615
 616	// If item is above the viewport, make it the first item
 617	if rItem.start < start {
 618		if l.direction == DirectionForward {
 619			l.offset = rItem.start
 620		} else {
 621			if l.virtualHeight > 0 {
 622			l.offset = l.virtualHeight - rItem.end
 623		} else {
 624			l.offset = 0
 625		}
 626		}
 627	} else if rItem.end > end {
 628		// If item is below the viewport, make it the last item
 629		if l.direction == DirectionForward {
 630			l.offset = max(0, rItem.end - l.height + 1)
 631		} else {
 632			if l.virtualHeight > 0 {
 633			l.offset = max(0, l.virtualHeight - rItem.start - l.height + 1)
 634		} else {
 635			l.offset = 0
 636		}
 637		}
 638	}
 639}
 640
 641func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 642	inx, ok := l.indexMap.Get(l.selectedItem)
 643	if !ok || inx < 0 || inx >= len(l.itemPositions) {
 644		return nil
 645	}
 646	
 647	rItem := l.itemPositions[inx]
 648	start, end := l.viewPosition()
 649	// item bigger than the viewport do nothing
 650	if rItem.start <= start && rItem.end >= end {
 651		return nil
 652	}
 653	// item already in view do nothing
 654	if rItem.start >= start && rItem.end <= end {
 655		return nil
 656	}
 657
 658	itemMiddle := rItem.start + rItem.height/2
 659
 660	if itemMiddle < start {
 661		// select the first item in the viewport
 662		// the item is most likely an item coming after this item
 663		for {
 664			inx = l.firstSelectableItemBelow(inx)
 665			if inx == ItemNotFound {
 666				return nil
 667			}
 668			item, ok := l.items.Get(inx)
 669			if !ok {
 670				continue
 671			}
 672			if inx >= len(l.itemPositions) {
 673				continue
 674			}
 675			renderedItem := l.itemPositions[inx]
 676
 677			// If the item is bigger than the viewport, select it
 678			if renderedItem.start <= start && renderedItem.end >= end {
 679				l.selectedItem = item.ID()
 680				return l.render()
 681			}
 682			// item is in the view
 683			if renderedItem.start >= start && renderedItem.start <= end {
 684				l.selectedItem = item.ID()
 685				return l.render()
 686			}
 687		}
 688	} else if itemMiddle > end {
 689		// select the first item in the viewport
 690		// the item is most likely an item coming after this item
 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			if inx >= len(l.itemPositions) {
 701				continue
 702			}
 703			renderedItem := l.itemPositions[inx]
 704
 705			// If the item is bigger than the viewport, select it
 706			if renderedItem.start <= start && renderedItem.end >= end {
 707				l.selectedItem = item.ID()
 708				return l.render()
 709			}
 710			// item is in the view
 711			if renderedItem.end >= start && renderedItem.end <= end {
 712				l.selectedItem = item.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.viewCache.Del(item.ID())
 783			} else if item.ID() != l.selectedItem && f.IsFocused() {
 784				cmds = append(cmds, f.Blur())
 785				l.viewCache.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.viewCache.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.
 811// This is O(n) but only called when the list structure changes significantly.
 812func (l *list[T]) calculateItemPositions() {
 813	itemsLen := l.items.Len()
 814	
 815	// Resize positions slice if needed
 816	if len(l.itemPositions) != itemsLen {
 817		l.itemPositions = make([]itemPosition, itemsLen)
 818	}
 819	
 820	currentHeight := 0
 821	// Always calculate positions in forward order (logical positions)
 822	for i := 0; i < itemsLen; i++ {
 823		item, ok := l.items.Get(i)
 824		if !ok {
 825			continue
 826		}
 827
 828		// Get cached view or render new one
 829		var view string
 830		if cached, ok := l.viewCache.Get(item.ID()); ok {
 831			view = cached
 832		} else {
 833			view = item.View()
 834			l.viewCache.Set(item.ID(), view)
 835		}
 836		
 837		height := lipgloss.Height(view)
 838		
 839		l.itemPositions[i] = itemPosition{
 840			height: height,
 841			start:  currentHeight,
 842			end:    currentHeight + height - 1,
 843		}
 844		
 845		currentHeight += height
 846		if i < itemsLen-1 {
 847			currentHeight += l.gap
 848		}
 849	}
 850
 851	l.virtualHeight = currentHeight
 852}
 853
 854// updateItemPosition updates a single item's position and adjusts subsequent items.
 855// This is O(n) in worst case but only for items after the changed one.
 856func (l *list[T]) updateItemPosition(index int) {
 857	itemsLen := l.items.Len()
 858	if index < 0 || index >= itemsLen {
 859		return
 860	}
 861	
 862	item, ok := l.items.Get(index)
 863	if !ok {
 864		return
 865	}
 866	
 867	// Get new height
 868	view := item.View()
 869	l.viewCache.Set(item.ID(), view)
 870	newHeight := lipgloss.Height(view)
 871	
 872	// If height hasn't changed, no need to update
 873	if index < len(l.itemPositions) && l.itemPositions[index].height == newHeight {
 874		return
 875	}
 876	
 877	// Calculate starting position (from previous item or 0)
 878	var startPos int
 879	if index > 0 {
 880		startPos = l.itemPositions[index-1].end + 1 + l.gap
 881	}
 882	
 883	// Update this item
 884	oldHeight := 0
 885	if index < len(l.itemPositions) {
 886		oldHeight = l.itemPositions[index].height
 887	}
 888	heightDiff := newHeight - oldHeight
 889	
 890	l.itemPositions[index] = itemPosition{
 891		height: newHeight,
 892		start:  startPos,
 893		end:    startPos + newHeight - 1,
 894	}
 895	
 896	// Update all subsequent items' positions (shift by heightDiff)
 897	for i := index + 1; i < len(l.itemPositions); i++ {
 898		l.itemPositions[i].start += heightDiff
 899		l.itemPositions[i].end += heightDiff
 900	}
 901	
 902	// Update total height
 903	l.virtualHeight += heightDiff
 904}
 905
 906// renderVirtualScrolling renders only the visible portion of the list.
 907func (l *list[T]) renderVirtualScrolling() string {
 908	if l.items.Len() == 0 {
 909		return ""
 910	}
 911
 912	// Calculate viewport bounds
 913	viewStart, viewEnd := l.viewPosition()
 914	
 915	// Debug: Check if viewport is valid
 916	if viewEnd < viewStart {
 917		// Return empty viewport
 918		var lines []string
 919		for i := 0; i < l.height; i++ {
 920			lines = append(lines, "")
 921		}
 922		return strings.Join(lines, "\n")
 923	}
 924	
 925	// Check if we have any positions calculated
 926	if len(l.itemPositions) == 0 {
 927		// No items have been calculated yet, return empty
 928		var lines []string
 929		for i := 0; i < l.height; i++ {
 930			lines = append(lines, "")
 931		}
 932		return strings.Join(lines, "\n")
 933	}
 934	
 935	// Find which items are visible
 936	var visibleItems []struct {
 937		item  T
 938		pos   itemPosition
 939		index int
 940	}
 941	
 942	itemsLen := l.items.Len()
 943	for i := 0; i < itemsLen; i++ {
 944		item, ok := l.items.Get(i)
 945		if !ok {
 946			continue
 947		}
 948		
 949		if i >= len(l.itemPositions) {
 950			// Item not yet calculated, skip it
 951			continue
 952		}
 953		
 954		pos := l.itemPositions[i]
 955		
 956		// Check if item is visible (overlaps with viewport)
 957		if pos.end >= viewStart && pos.start <= viewEnd {
 958			visibleItems = append(visibleItems, struct {
 959				item  T
 960				pos   itemPosition
 961				index int
 962			}{item, pos, i})
 963		}
 964		
 965		// Early exit if we've passed the viewport
 966		if pos.start > viewEnd {
 967			break
 968		}
 969	}
 970	
 971	if len(visibleItems) == 0 {
 972		// No visible items found - this shouldn't happen if viewport is valid
 973		// Return empty lines to maintain height
 974		var lines []string
 975		for i := 0; i < l.height; i++ {
 976			lines = append(lines, "")
 977		}
 978		return strings.Join(lines, "\n")
 979	}
 980	
 981	// Render visible items
 982	var b strings.Builder
 983	currentLine := viewStart
 984	
 985	// Handle first visible item
 986	firstVisible := visibleItems[0]
 987	if firstVisible.pos.start < viewStart {
 988		// We're starting mid-item, render partial
 989		if cached, ok := l.viewCache.Get(firstVisible.item.ID()); ok && cached != "" {
 990			lines := strings.Split(cached, "\n")
 991			skipLines := viewStart - firstVisible.pos.start
 992			if skipLines >= 0 && skipLines < len(lines) {
 993				for i := skipLines; i < len(lines) && currentLine <= viewEnd; i++ {
 994					if b.Len() > 0 {
 995						b.WriteByte('\n')
 996					}
 997					b.WriteString(lines[i])
 998					currentLine++
 999				}
1000			}
1001		}
1002	} else if firstVisible.pos.start > viewStart {
1003		// Add empty lines before first item
1004		for currentLine < firstVisible.pos.start && currentLine <= viewEnd {
1005			if b.Len() > 0 {
1006				b.WriteByte('\n')
1007			}
1008			currentLine++
1009		}
1010	}
1011	
1012	// Render fully visible items
1013	for i, vis := range visibleItems {
1014		if currentLine > viewEnd {
1015			break
1016		}
1017		
1018		// Skip first item if we already rendered it partially
1019		if i == 0 && firstVisible.pos.start < viewStart {
1020			// Update currentLine to where we left off after partial rendering
1021			currentLine = viewStart + (firstVisible.pos.end - firstVisible.pos.start + 1) - (viewStart - firstVisible.pos.start)
1022			continue
1023		}
1024		
1025		// Add gap before item (except for first visible item in viewport)
1026		if i > 0 || (i == 0 && firstVisible.pos.start >= viewStart) {
1027			// Only add gap if this isn't the very first item in the viewport
1028			if currentLine > viewStart && currentLine <= viewEnd {
1029				for j := 0; j < l.gap && currentLine <= viewEnd; j++ {
1030					if b.Len() > 0 {
1031						b.WriteByte('\n')
1032					}
1033					currentLine++
1034				}
1035			}
1036		}
1037		
1038		// Render item or use cache
1039		var view string
1040		if cached, ok := l.viewCache.Get(vis.item.ID()); ok && cached != "" {
1041			view = cached
1042		} else {
1043			view = vis.item.View()
1044			// Update cache
1045			l.viewCache.Set(vis.item.ID(), view)
1046		}
1047		
1048		// Handle partial rendering if item extends beyond viewport
1049		lines := strings.Split(view, "\n")
1050		for _, line := range lines {
1051			if currentLine > viewEnd {
1052				break
1053			}
1054			if b.Len() > 0 {
1055				b.WriteByte('\n')
1056			}
1057			b.WriteString(line)
1058			currentLine++
1059		}
1060	}
1061	
1062	// Fill remaining viewport with empty lines if needed
1063	for currentLine <= viewEnd {
1064		if b.Len() > 0 {
1065			b.WriteByte('\n')
1066		}
1067		currentLine++
1068	}
1069	
1070	return b.String()
1071}
1072
1073
1074
1075// AppendItem implements List.
1076func (l *list[T]) AppendItem(item T) tea.Cmd {
1077	var cmds []tea.Cmd
1078	cmd := item.Init()
1079	if cmd != nil {
1080		cmds = append(cmds, cmd)
1081	}
1082
1083	l.items.Append(item)
1084	l.indexMap = csync.NewMap[string, int]()
1085	for inx, item := range slices.Collect(l.items.Seq()) {
1086		l.indexMap.Set(item.ID(), inx)
1087	}
1088	if l.width > 0 && l.height > 0 {
1089		cmd = item.SetSize(l.width, l.height)
1090		if cmd != nil {
1091			cmds = append(cmds, cmd)
1092		}
1093	}
1094	cmd = l.render()
1095	if cmd != nil {
1096		cmds = append(cmds, cmd)
1097	}
1098	if l.direction == DirectionBackward {
1099		if l.offset == 0 {
1100			cmd = l.GoToBottom()
1101			if cmd != nil {
1102				cmds = append(cmds, cmd)
1103			}
1104		} else {
1105			// Get the new item's position to adjust offset
1106			newInx := l.items.Len() - 1
1107			if newInx < len(l.itemPositions) {
1108				newItem := l.itemPositions[newInx]
1109				newLines := newItem.height
1110				if l.items.Len() > 1 {
1111					newLines += l.gap
1112				}
1113				if l.virtualHeight > 0 {
1114					l.offset = min(l.virtualHeight-1, l.offset+newLines)
1115				}
1116			}
1117		}
1118	}
1119	return tea.Sequence(cmds...)
1120}
1121
1122// Blur implements List.
1123func (l *list[T]) Blur() tea.Cmd {
1124	l.focused = false
1125	return l.render()
1126}
1127
1128// DeleteItem implements List.
1129func (l *list[T]) DeleteItem(id string) tea.Cmd {
1130	inx, ok := l.indexMap.Get(id)
1131	if !ok {
1132		return nil
1133	}
1134	l.items.Delete(inx)
1135	l.viewCache.Del(id)
1136	// Rebuild index map
1137	l.indexMap = csync.NewMap[string, int]()
1138	for inx, item := range slices.Collect(l.items.Seq()) {
1139		l.indexMap.Set(item.ID(), inx)
1140	}
1141
1142	if l.selectedItem == id {
1143		if inx > 0 {
1144			item, ok := l.items.Get(inx - 1)
1145			if ok {
1146				l.selectedItem = item.ID()
1147			} else {
1148				l.selectedItem = ""
1149			}
1150		} else {
1151			l.selectedItem = ""
1152		}
1153	}
1154	cmd := l.render()
1155	if l.rendered != "" {
1156		renderedHeight := l.virtualHeight
1157		if renderedHeight <= l.height {
1158			l.offset = 0
1159		} else {
1160			maxOffset := renderedHeight - l.height
1161			if l.offset > maxOffset {
1162				l.offset = maxOffset
1163			}
1164		}
1165	}
1166	return cmd
1167}
1168
1169// Focus implements List.
1170func (l *list[T]) Focus() tea.Cmd {
1171	l.focused = true
1172	return l.render()
1173}
1174
1175// GetSize implements List.
1176func (l *list[T]) GetSize() (int, int) {
1177	return l.width, l.height
1178}
1179
1180// GoToBottom implements List.
1181func (l *list[T]) GoToBottom() tea.Cmd {
1182	l.offset = 0
1183	l.selectedItem = ""
1184	l.direction = DirectionBackward
1185	return l.render()
1186}
1187
1188// GoToTop implements List.
1189func (l *list[T]) GoToTop() tea.Cmd {
1190	l.offset = 0
1191	l.selectedItem = ""
1192	l.direction = DirectionForward
1193	return l.render()
1194}
1195
1196// IsFocused implements List.
1197func (l *list[T]) IsFocused() bool {
1198	return l.focused
1199}
1200
1201// Items implements List.
1202func (l *list[T]) Items() []T {
1203	return slices.Collect(l.items.Seq())
1204}
1205
1206func (l *list[T]) incrementOffset(n int) {
1207	renderedHeight := l.virtualHeight
1208	// no need for offset
1209	if renderedHeight <= l.height {
1210		return
1211	}
1212	maxOffset := renderedHeight - l.height
1213	n = min(n, maxOffset-l.offset)
1214	if n <= 0 {
1215		return
1216	}
1217	l.offset += n
1218}
1219
1220func (l *list[T]) decrementOffset(n int) {
1221	n = min(n, l.offset)
1222	if n <= 0 {
1223		return
1224	}
1225	l.offset -= n
1226	if l.offset < 0 {
1227		l.offset = 0
1228	}
1229}
1230
1231// MoveDown implements List.
1232func (l *list[T]) MoveDown(n int) tea.Cmd {
1233	oldOffset := l.offset
1234	if l.direction == DirectionForward {
1235		l.incrementOffset(n)
1236	} else {
1237		l.decrementOffset(n)
1238	}
1239
1240	if oldOffset == l.offset {
1241		// no change in offset, so no need to change selection
1242		return nil
1243	}
1244	// if we are not actively selecting move the whole selection down
1245	if l.hasSelection() && !l.selectionActive {
1246		if l.selectionStartLine < l.selectionEndLine {
1247			l.selectionStartLine -= n
1248			l.selectionEndLine -= n
1249		} else {
1250			l.selectionStartLine -= n
1251			l.selectionEndLine -= n
1252		}
1253	}
1254	if l.selectionActive {
1255		if l.selectionStartLine < l.selectionEndLine {
1256			l.selectionStartLine -= n
1257		} else {
1258			l.selectionEndLine -= n
1259		}
1260	}
1261	return l.changeSelectionWhenScrolling()
1262}
1263
1264// MoveUp implements List.
1265func (l *list[T]) MoveUp(n int) tea.Cmd {
1266	oldOffset := l.offset
1267	if l.direction == DirectionForward {
1268		l.decrementOffset(n)
1269	} else {
1270		l.incrementOffset(n)
1271	}
1272
1273	if oldOffset == l.offset {
1274		// no change in offset, so no need to change selection
1275		return nil
1276	}
1277
1278	if l.hasSelection() && !l.selectionActive {
1279		if l.selectionStartLine > l.selectionEndLine {
1280			l.selectionStartLine += n
1281			l.selectionEndLine += n
1282		} else {
1283			l.selectionStartLine += n
1284			l.selectionEndLine += n
1285		}
1286	}
1287	if l.selectionActive {
1288		if l.selectionStartLine > l.selectionEndLine {
1289			l.selectionStartLine += n
1290		} else {
1291			l.selectionEndLine += n
1292		}
1293	}
1294	return l.changeSelectionWhenScrolling()
1295}
1296
1297// PrependItem implements List.
1298func (l *list[T]) PrependItem(item T) tea.Cmd {
1299	cmds := []tea.Cmd{
1300		item.Init(),
1301	}
1302	l.items.Prepend(item)
1303	l.indexMap = csync.NewMap[string, int]()
1304	for inx, item := range slices.Collect(l.items.Seq()) {
1305		l.indexMap.Set(item.ID(), inx)
1306	}
1307	if l.width > 0 && l.height > 0 {
1308		cmds = append(cmds, item.SetSize(l.width, l.height))
1309	}
1310	cmds = append(cmds, l.render())
1311	if l.direction == DirectionForward {
1312		if l.offset == 0 {
1313			cmd := l.GoToTop()
1314			if cmd != nil {
1315				cmds = append(cmds, cmd)
1316			}
1317		} else {
1318			// Get the new item's position to adjust offset
1319			newInx := l.items.Len() - 1
1320			if newInx < len(l.itemPositions) {
1321				newItem := l.itemPositions[newInx]
1322				newLines := newItem.height
1323				if l.items.Len() > 1 {
1324					newLines += l.gap
1325				}
1326				if l.virtualHeight > 0 {
1327					l.offset = min(l.virtualHeight-1, l.offset+newLines)
1328				}
1329			}
1330		}
1331	}
1332	return tea.Batch(cmds...)
1333}
1334
1335// SelectItemAbove implements List.
1336func (l *list[T]) SelectItemAbove() tea.Cmd {
1337	inx, ok := l.indexMap.Get(l.selectedItem)
1338	if !ok {
1339		return nil
1340	}
1341
1342	newIndex := l.firstSelectableItemAbove(inx)
1343	if newIndex == ItemNotFound {
1344		// no item above
1345		return nil
1346	}
1347	var cmds []tea.Cmd
1348	if newIndex == 1 {
1349		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1350		if peakAboveIndex == ItemNotFound {
1351			// this means there is a section above move to the top
1352			cmd := l.GoToTop()
1353			if cmd != nil {
1354				cmds = append(cmds, cmd)
1355			}
1356		}
1357	}
1358	item, ok := l.items.Get(newIndex)
1359	if !ok {
1360		return nil
1361	}
1362	l.selectedItem = item.ID()
1363	l.movingByItem = true
1364	renderCmd := l.render()
1365	if renderCmd != nil {
1366		cmds = append(cmds, renderCmd)
1367	}
1368	return tea.Sequence(cmds...)
1369}
1370
1371// SelectItemBelow implements List.
1372func (l *list[T]) SelectItemBelow() tea.Cmd {
1373	inx, ok := l.indexMap.Get(l.selectedItem)
1374	if !ok {
1375		return nil
1376	}
1377
1378	newIndex := l.firstSelectableItemBelow(inx)
1379	if newIndex == ItemNotFound {
1380		// no item above
1381		return nil
1382	}
1383	item, ok := l.items.Get(newIndex)
1384	if !ok {
1385		return nil
1386	}
1387	l.selectedItem = item.ID()
1388	l.movingByItem = true
1389	return l.render()
1390}
1391
1392// SelectedItem implements List.
1393func (l *list[T]) SelectedItem() *T {
1394	inx, ok := l.indexMap.Get(l.selectedItem)
1395	if !ok {
1396		return nil
1397	}
1398	if inx > l.items.Len()-1 {
1399		return nil
1400	}
1401	item, ok := l.items.Get(inx)
1402	if !ok {
1403		return nil
1404	}
1405	return &item
1406}
1407
1408// SetItems implements List.
1409func (l *list[T]) SetItems(items []T) tea.Cmd {
1410	l.items.SetSlice(items)
1411	var cmds []tea.Cmd
1412	for inx, item := range slices.Collect(l.items.Seq()) {
1413		if i, ok := any(item).(Indexable); ok {
1414			i.SetIndex(inx)
1415		}
1416		cmds = append(cmds, item.Init())
1417	}
1418	cmds = append(cmds, l.reset(""))
1419	return tea.Batch(cmds...)
1420}
1421
1422// SetSelected implements List.
1423func (l *list[T]) SetSelected(id string) tea.Cmd {
1424	l.selectedItem = id
1425	return l.render()
1426}
1427
1428func (l *list[T]) reset(selectedItem string) tea.Cmd {
1429	var cmds []tea.Cmd
1430	l.rendered = ""
1431	l.offset = 0
1432	l.selectedItem = selectedItem
1433	l.indexMap = csync.NewMap[string, int]()
1434	l.viewCache = csync.NewMap[string, string]()
1435	l.itemPositions = nil // Will be recalculated
1436	l.virtualHeight = 0
1437	for inx, item := range slices.Collect(l.items.Seq()) {
1438		l.indexMap.Set(item.ID(), inx)
1439		if l.width > 0 && l.height > 0 {
1440			cmds = append(cmds, item.SetSize(l.width, l.height))
1441		}
1442	}
1443	cmds = append(cmds, l.render())
1444	return tea.Batch(cmds...)
1445}
1446
1447// SetSize implements List.
1448func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1449	oldWidth := l.width
1450	l.width = width
1451	l.height = height
1452	if oldWidth != width {
1453		cmd := l.reset(l.selectedItem)
1454		return cmd
1455	}
1456	return nil
1457}
1458
1459// UpdateItem implements List.
1460func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1461	var cmds []tea.Cmd
1462	if inx, ok := l.indexMap.Get(id); ok {
1463		// Store old height if we have it
1464		var oldHeight int
1465		hasOldItem := false
1466		if inx < len(l.itemPositions) {
1467			oldHeight = l.itemPositions[inx].height
1468			hasOldItem = true
1469		}
1470		
1471		oldPosition := l.offset
1472		if l.direction == DirectionBackward {
1473			if l.virtualHeight > 0 {
1474				oldPosition = (l.virtualHeight - 1) - l.offset
1475			} else {
1476				oldPosition = 0
1477			}
1478		}
1479
1480		// Update the item
1481		l.items.Set(inx, item)
1482		
1483		// Clear cache for this item
1484		l.viewCache.Del(id)
1485		
1486		cmd := l.render()
1487
1488		// need to check for nil because of sequence not handling nil
1489		if cmd != nil {
1490			cmds = append(cmds, cmd)
1491		}
1492		
1493		// Adjust offset if needed based on height change
1494		if hasOldItem && inx < len(l.itemPositions) {
1495			newHeight := l.itemPositions[inx].height
1496			diff := newHeight - oldHeight
1497			
1498			if l.direction == DirectionBackward {
1499				// if we are the last item and there is no offset
1500				// make sure to go to the bottom
1501				if oldPosition < l.itemPositions[inx].end {
1502					if diff != 0 && l.virtualHeight > 0 {
1503						l.offset = util.Clamp(l.offset+diff, 0, l.virtualHeight-1)
1504					}
1505				}
1506			} else if hasOldItem && l.offset > l.itemPositions[inx].start {
1507				if diff != 0 && l.virtualHeight > 0 {
1508					l.offset = util.Clamp(l.offset+diff, 0, l.virtualHeight-1)
1509				}
1510			}
1511		}
1512	}
1513	return tea.Sequence(cmds...)
1514}
1515
1516func (l *list[T]) hasSelection() bool {
1517	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1518}
1519
1520// StartSelection implements List.
1521func (l *list[T]) StartSelection(col, line int) {
1522	l.selectionStartCol = col
1523	l.selectionStartLine = line
1524	l.selectionEndCol = col
1525	l.selectionEndLine = line
1526	l.selectionActive = true
1527}
1528
1529// EndSelection implements List.
1530func (l *list[T]) EndSelection(col, line int) {
1531	if !l.selectionActive {
1532		return
1533	}
1534	l.selectionEndCol = col
1535	l.selectionEndLine = line
1536}
1537
1538func (l *list[T]) SelectionStop() {
1539	l.selectionActive = false
1540}
1541
1542func (l *list[T]) SelectionClear() {
1543	l.selectionStartCol = -1
1544	l.selectionStartLine = -1
1545	l.selectionEndCol = -1
1546	l.selectionEndLine = -1
1547	l.selectionActive = false
1548}
1549
1550func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1551	lines := strings.Split(l.rendered, "\n")
1552	for i, l := range lines {
1553		lines[i] = ansi.Strip(l)
1554	}
1555
1556	if l.direction == DirectionBackward && len(lines) > l.height {
1557		line = ((len(lines) - 1) - l.height) + line + 1
1558	}
1559
1560	if l.offset > 0 {
1561		if l.direction == DirectionBackward {
1562			line -= l.offset
1563		} else {
1564			line += l.offset
1565		}
1566	}
1567
1568	if line < 0 || line >= len(lines) {
1569		return 0, 0
1570	}
1571
1572	currentLine := lines[line]
1573	gr := uniseg.NewGraphemes(currentLine)
1574	startCol = -1
1575	upTo := col
1576	for gr.Next() {
1577		if gr.IsWordBoundary() && upTo > 0 {
1578			startCol = col - upTo + 1
1579		} else if gr.IsWordBoundary() && upTo < 0 {
1580			endCol = col - upTo + 1
1581			break
1582		}
1583		if upTo == 0 && gr.Str() == " " {
1584			return 0, 0
1585		}
1586		upTo -= 1
1587	}
1588	if startCol == -1 {
1589		return 0, 0
1590	}
1591	return
1592}
1593
1594func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1595	lines := strings.Split(l.rendered, "\n")
1596	for i, l := range lines {
1597		lines[i] = ansi.Strip(l)
1598		for _, icon := range styles.SelectionIgnoreIcons {
1599			lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1600		}
1601	}
1602	if l.direction == DirectionBackward && len(lines) > l.height {
1603		line = (len(lines) - 1) - l.height + line + 1
1604	}
1605
1606	if l.offset > 0 {
1607		if l.direction == DirectionBackward {
1608			line -= l.offset
1609		} else {
1610			line += l.offset
1611		}
1612	}
1613
1614	// Ensure line is within bounds
1615	if line < 0 || line >= len(lines) {
1616		return 0, 0, false
1617	}
1618
1619	if strings.TrimSpace(lines[line]) == "" {
1620		return 0, 0, false
1621	}
1622
1623	// Find start of paragraph (search backwards for empty line or start of text)
1624	startLine = line
1625	for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1626		startLine--
1627	}
1628
1629	// Find end of paragraph (search forwards for empty line or end of text)
1630	endLine = line
1631	for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1632		endLine++
1633	}
1634
1635	// revert the line numbers if we are in backward direction
1636	if l.direction == DirectionBackward && len(lines) > l.height {
1637		startLine = startLine - (len(lines) - 1) + l.height - 1
1638		endLine = endLine - (len(lines) - 1) + l.height - 1
1639	}
1640	if l.offset > 0 {
1641		if l.direction == DirectionBackward {
1642			startLine += l.offset
1643			endLine += l.offset
1644		} else {
1645			startLine -= l.offset
1646			endLine -= l.offset
1647		}
1648	}
1649	return startLine, endLine, true
1650}
1651
1652// SelectWord selects the word at the given position.
1653func (l *list[T]) SelectWord(col, line int) {
1654	startCol, endCol := l.findWordBoundaries(col, line)
1655	l.selectionStartCol = startCol
1656	l.selectionStartLine = line
1657	l.selectionEndCol = endCol
1658	l.selectionEndLine = line
1659	l.selectionActive = false // Not actively selecting, just selected
1660}
1661
1662// SelectParagraph selects the paragraph at the given position.
1663func (l *list[T]) SelectParagraph(col, line int) {
1664	startLine, endLine, found := l.findParagraphBoundaries(line)
1665	if !found {
1666		return
1667	}
1668	l.selectionStartCol = 0
1669	l.selectionStartLine = startLine
1670	l.selectionEndCol = l.width - 1
1671	l.selectionEndLine = endLine
1672	l.selectionActive = false // Not actively selecting, just selected
1673}
1674
1675// HasSelection returns whether there is an active selection.
1676func (l *list[T]) HasSelection() bool {
1677	return l.hasSelection()
1678}
1679
1680// GetSelectedText returns the currently selected text.
1681func (l *list[T]) GetSelectedText(paddingLeft int) string {
1682	if !l.hasSelection() {
1683		return ""
1684	}
1685
1686	return l.selectionView(l.View(), true)
1687}