list.go

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