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