list.go

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