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