list.go

   1package list
   2
   3import (
   4	"strings"
   5	"sync"
   6
   7	"github.com/charmbracelet/bubbles/v2/key"
   8	tea "github.com/charmbracelet/bubbletea/v2"
   9	"github.com/charmbracelet/crush/internal/tui/components/anim"
  10	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  11	"github.com/charmbracelet/crush/internal/tui/styles"
  12	"github.com/charmbracelet/crush/internal/tui/util"
  13	"github.com/charmbracelet/lipgloss/v2"
  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 = cell.Style.Background(ts.GetBackground()).Foreground(ts.GetForeground())
 479				scr.SetCell(x, y, cell)
 480			}
 481		}
 482
 483		if textOnly {
 484			// Make sure we add a newline after each line of selected text
 485			selectedText.WriteByte('\n')
 486		}
 487	}
 488
 489	if textOnly {
 490		return strings.TrimSpace(selectedText.String())
 491	}
 492
 493	return scr.Render()
 494}
 495
 496func (l *list[T]) View() string {
 497	if l.height <= 0 || l.width <= 0 {
 498		return ""
 499	}
 500
 501	if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
 502		return l.cachedView
 503	}
 504
 505	t := styles.CurrentTheme()
 506
 507	start, end := l.viewPosition()
 508	viewStart := max(0, start)
 509	viewEnd := end
 510
 511	if viewStart > viewEnd {
 512		return ""
 513	}
 514
 515	view := l.getLines(viewStart, viewEnd)
 516
 517	if l.resize {
 518		return view
 519	}
 520
 521	view = t.S().Base.
 522		Height(l.height).
 523		Width(l.width).
 524		Render(view)
 525
 526	if !l.hasSelection() {
 527		l.cachedView = view
 528		l.cachedViewOffset = l.offset
 529		l.cachedViewDirty = false
 530		return view
 531	}
 532
 533	return l.selectionView(view, false)
 534}
 535
 536func (l *list[T]) viewPosition() (int, int) {
 537	start, end := 0, 0
 538	renderedLines := l.renderedHeight - 1
 539	if l.direction == DirectionForward {
 540		start = max(0, l.offset)
 541		end = min(l.offset+l.height-1, renderedLines)
 542	} else {
 543		start = max(0, renderedLines-l.offset-l.height+1)
 544		end = max(0, renderedLines-l.offset)
 545	}
 546	start = min(start, end)
 547	return start, end
 548}
 549
 550func (l *list[T]) setRendered(rendered string) {
 551	l.rendered = rendered
 552	l.renderedHeight = lipgloss.Height(rendered)
 553	l.cachedViewDirty = true // Mark view cache as dirty
 554
 555	if len(rendered) > 0 {
 556		l.lineOffsets = make([]int, 0, l.renderedHeight)
 557		l.lineOffsets = append(l.lineOffsets, 0)
 558
 559		offset := 0
 560		for {
 561			idx := strings.IndexByte(rendered[offset:], '\n')
 562			if idx == -1 {
 563				break
 564			}
 565			offset += idx + 1
 566			l.lineOffsets = append(l.lineOffsets, offset)
 567		}
 568	} else {
 569		l.lineOffsets = nil
 570	}
 571}
 572
 573func (l *list[T]) getLines(start, end int) string {
 574	if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
 575		return ""
 576	}
 577
 578	if end >= len(l.lineOffsets) {
 579		end = len(l.lineOffsets) - 1
 580	}
 581	if start > end {
 582		return ""
 583	}
 584
 585	startOffset := l.lineOffsets[start]
 586	var endOffset int
 587	if end+1 < len(l.lineOffsets) {
 588		endOffset = l.lineOffsets[end+1] - 1
 589	} else {
 590		endOffset = len(l.rendered)
 591	}
 592
 593	if startOffset >= len(l.rendered) {
 594		return ""
 595	}
 596	endOffset = min(endOffset, len(l.rendered))
 597
 598	return l.rendered[startOffset:endOffset]
 599}
 600
 601// getLine returns a single line from the rendered content using lineOffsets.
 602// This avoids allocating a new string for each line like strings.Split does.
 603func (l *list[T]) getLine(index int) string {
 604	if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
 605		return ""
 606	}
 607
 608	startOffset := l.lineOffsets[index]
 609	var endOffset int
 610	if index+1 < len(l.lineOffsets) {
 611		endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
 612	} else {
 613		endOffset = len(l.rendered)
 614	}
 615
 616	if startOffset >= len(l.rendered) {
 617		return ""
 618	}
 619	endOffset = min(endOffset, len(l.rendered))
 620
 621	return l.rendered[startOffset:endOffset]
 622}
 623
 624// lineCount returns the number of lines in the rendered content.
 625func (l *list[T]) lineCount() int {
 626	return len(l.lineOffsets)
 627}
 628
 629func (l *list[T]) recalculateItemPositions() {
 630	l.recalculateItemPositionsFrom(0)
 631}
 632
 633func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
 634	var currentContentHeight int
 635
 636	if startIdx > 0 && startIdx <= len(l.items) {
 637		prevItem := l.items[startIdx-1]
 638		if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
 639			currentContentHeight = rItem.end + 1 + l.gap
 640		}
 641	}
 642
 643	for i := startIdx; i < len(l.items); i++ {
 644		item := l.items[i]
 645		rItem, ok := l.renderedItems[item.ID()]
 646		if !ok {
 647			continue
 648		}
 649		rItem.start = currentContentHeight
 650		rItem.end = currentContentHeight + rItem.height - 1
 651		l.renderedItems[item.ID()] = rItem
 652		currentContentHeight = rItem.end + 1 + l.gap
 653	}
 654}
 655
 656func (l *list[T]) render() tea.Cmd {
 657	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
 658		return nil
 659	}
 660	l.setDefaultSelected()
 661
 662	var focusChangeCmd tea.Cmd
 663	if l.focused {
 664		focusChangeCmd = l.focusSelectedItem()
 665	} else {
 666		focusChangeCmd = l.blurSelectedItem()
 667	}
 668	if l.rendered != "" {
 669		rendered, _ := l.renderIterator(0, false, "")
 670		l.setRendered(rendered)
 671		if l.direction == DirectionBackward {
 672			l.recalculateItemPositions()
 673		}
 674		if l.focused {
 675			l.scrollToSelection()
 676		}
 677		return focusChangeCmd
 678	}
 679	rendered, finishIndex := l.renderIterator(0, true, "")
 680	l.setRendered(rendered)
 681	if l.direction == DirectionBackward {
 682		l.recalculateItemPositions()
 683	}
 684
 685	l.offset = 0
 686	rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
 687	l.setRendered(rendered)
 688	if l.direction == DirectionBackward {
 689		l.recalculateItemPositions()
 690	}
 691	if l.focused {
 692		l.scrollToSelection()
 693	}
 694
 695	return focusChangeCmd
 696}
 697
 698func (l *list[T]) setDefaultSelected() {
 699	if l.selectedItemIdx < 0 {
 700		if l.direction == DirectionForward {
 701			l.selectFirstItem()
 702		} else {
 703			l.selectLastItem()
 704		}
 705	}
 706}
 707
 708func (l *list[T]) scrollToSelection() {
 709	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
 710		l.selectedItemIdx = -1
 711		l.setDefaultSelected()
 712		return
 713	}
 714	item := l.items[l.selectedItemIdx]
 715	rItem, ok := l.renderedItems[item.ID()]
 716	if !ok {
 717		l.selectedItemIdx = -1
 718		l.setDefaultSelected()
 719		return
 720	}
 721
 722	start, end := l.viewPosition()
 723	if rItem.start <= start && rItem.end >= end {
 724		return
 725	}
 726	if l.movingByItem {
 727		if rItem.start >= start && rItem.end <= end {
 728			return
 729		}
 730		defer func() { l.movingByItem = false }()
 731	} else {
 732		if rItem.start >= start && rItem.start <= end {
 733			return
 734		}
 735		if rItem.end >= start && rItem.end <= end {
 736			return
 737		}
 738	}
 739
 740	if rItem.height >= l.height {
 741		if l.direction == DirectionForward {
 742			l.offset = rItem.start
 743		} else {
 744			l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
 745		}
 746		return
 747	}
 748
 749	renderedLines := l.renderedHeight - 1
 750
 751	if rItem.start < start {
 752		if l.direction == DirectionForward {
 753			l.offset = rItem.start
 754		} else {
 755			l.offset = max(0, renderedLines-rItem.start-l.height+1)
 756		}
 757	} else if rItem.end > end {
 758		if l.direction == DirectionForward {
 759			l.offset = max(0, rItem.end-l.height+1)
 760		} else {
 761			l.offset = max(0, renderedLines-rItem.end)
 762		}
 763	}
 764}
 765
 766func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 767	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
 768		return nil
 769	}
 770	item := l.items[l.selectedItemIdx]
 771	rItem, ok := l.renderedItems[item.ID()]
 772	if !ok {
 773		return nil
 774	}
 775	start, end := l.viewPosition()
 776	// item bigger than the viewport do nothing
 777	if rItem.start <= start && rItem.end >= end {
 778		return nil
 779	}
 780	// item already in view do nothing
 781	if rItem.start >= start && rItem.end <= end {
 782		return nil
 783	}
 784
 785	itemMiddle := rItem.start + rItem.height/2
 786
 787	if itemMiddle < start {
 788		// select the first item in the viewport
 789		// the item is most likely an item coming after this item
 790		inx := l.selectedItemIdx
 791		for {
 792			inx = l.firstSelectableItemBelow(inx)
 793			if inx == ItemNotFound {
 794				return nil
 795			}
 796			if inx < 0 || inx >= len(l.items) {
 797				continue
 798			}
 799
 800			item := l.items[inx]
 801			renderedItem, ok := l.renderedItems[item.ID()]
 802			if !ok {
 803				continue
 804			}
 805
 806			// If the item is bigger than the viewport, select it
 807			if renderedItem.start <= start && renderedItem.end >= end {
 808				l.selectedItemIdx = inx
 809				return l.render()
 810			}
 811			// item is in the view
 812			if renderedItem.start >= start && renderedItem.start <= end {
 813				l.selectedItemIdx = inx
 814				return l.render()
 815			}
 816		}
 817	} else if itemMiddle > end {
 818		// select the first item in the viewport
 819		// the item is most likely an item coming after this item
 820		inx := l.selectedItemIdx
 821		for {
 822			inx = l.firstSelectableItemAbove(inx)
 823			if inx == ItemNotFound {
 824				return nil
 825			}
 826			if inx < 0 || inx >= len(l.items) {
 827				continue
 828			}
 829
 830			item := l.items[inx]
 831			renderedItem, ok := l.renderedItems[item.ID()]
 832			if !ok {
 833				continue
 834			}
 835
 836			// If the item is bigger than the viewport, select it
 837			if renderedItem.start <= start && renderedItem.end >= end {
 838				l.selectedItemIdx = inx
 839				return l.render()
 840			}
 841			// item is in the view
 842			if renderedItem.end >= start && renderedItem.end <= end {
 843				l.selectedItemIdx = inx
 844				return l.render()
 845			}
 846		}
 847	}
 848	return nil
 849}
 850
 851func (l *list[T]) selectFirstItem() {
 852	inx := l.firstSelectableItemBelow(-1)
 853	if inx != ItemNotFound {
 854		l.selectedItemIdx = inx
 855	}
 856}
 857
 858func (l *list[T]) selectLastItem() {
 859	inx := l.firstSelectableItemAbove(len(l.items))
 860	if inx != ItemNotFound {
 861		l.selectedItemIdx = inx
 862	}
 863}
 864
 865func (l *list[T]) firstSelectableItemAbove(inx int) int {
 866	for i := inx - 1; i >= 0; i-- {
 867		if i < 0 || i >= len(l.items) {
 868			continue
 869		}
 870
 871		item := l.items[i]
 872		if _, ok := any(item).(layout.Focusable); ok {
 873			return i
 874		}
 875	}
 876	if inx == 0 && l.wrap {
 877		return l.firstSelectableItemAbove(len(l.items))
 878	}
 879	return ItemNotFound
 880}
 881
 882func (l *list[T]) firstSelectableItemBelow(inx int) int {
 883	itemsLen := len(l.items)
 884	for i := inx + 1; i < itemsLen; i++ {
 885		if i < 0 || i >= len(l.items) {
 886			continue
 887		}
 888
 889		item := l.items[i]
 890		if _, ok := any(item).(layout.Focusable); ok {
 891			return i
 892		}
 893	}
 894	if inx == itemsLen-1 && l.wrap {
 895		return l.firstSelectableItemBelow(-1)
 896	}
 897	return ItemNotFound
 898}
 899
 900func (l *list[T]) focusSelectedItem() tea.Cmd {
 901	if l.selectedItemIdx < 0 || !l.focused {
 902		return nil
 903	}
 904	// Pre-allocate with expected capacity
 905	cmds := make([]tea.Cmd, 0, 2)
 906
 907	// Blur the previously selected item if it's different
 908	if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
 909		prevItem := l.items[l.prevSelectedItemIdx]
 910		if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
 911			cmds = append(cmds, f.Blur())
 912			// Mark cache as needing update, but don't delete yet
 913			// This allows the render to potentially reuse it
 914			delete(l.renderedItems, prevItem.ID())
 915		}
 916	}
 917
 918	// Focus the currently selected item
 919	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
 920		item := l.items[l.selectedItemIdx]
 921		if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
 922			cmds = append(cmds, f.Focus())
 923			// Mark for re-render
 924			delete(l.renderedItems, item.ID())
 925		}
 926	}
 927
 928	l.prevSelectedItemIdx = l.selectedItemIdx
 929	return tea.Batch(cmds...)
 930}
 931
 932func (l *list[T]) blurSelectedItem() tea.Cmd {
 933	if l.selectedItemIdx < 0 || l.focused {
 934		return nil
 935	}
 936
 937	// Blur the currently selected item
 938	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
 939		item := l.items[l.selectedItemIdx]
 940		if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
 941			delete(l.renderedItems, item.ID())
 942			return f.Blur()
 943		}
 944	}
 945
 946	return nil
 947}
 948
 949// renderFragment holds updated rendered view fragments
 950type renderFragment struct {
 951	view string
 952	gap  int
 953}
 954
 955// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
 956// returns the last index and the rendered content so far
 957// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
 958func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
 959	// Pre-allocate fragments with expected capacity
 960	itemsLen := len(l.items)
 961	expectedFragments := itemsLen - startInx
 962	if limitHeight && l.height > 0 {
 963		expectedFragments = min(expectedFragments, l.height)
 964	}
 965	fragments := make([]renderFragment, 0, expectedFragments)
 966
 967	currentContentHeight := lipgloss.Height(rendered) - 1
 968	finalIndex := itemsLen
 969
 970	// first pass: accumulate all fragments to render until the height limit is
 971	// reached
 972	for i := startInx; i < itemsLen; i++ {
 973		if limitHeight && currentContentHeight >= l.height {
 974			finalIndex = i
 975			break
 976		}
 977		// cool way to go through the list in both directions
 978		inx := i
 979
 980		if l.direction != DirectionForward {
 981			inx = (itemsLen - 1) - i
 982		}
 983
 984		if inx < 0 || inx >= len(l.items) {
 985			continue
 986		}
 987
 988		item := l.items[inx]
 989
 990		var rItem renderedItem
 991		if cache, ok := l.renderedItems[item.ID()]; ok {
 992			rItem = cache
 993		} else {
 994			rItem = l.renderItem(item)
 995			rItem.start = currentContentHeight
 996			rItem.end = currentContentHeight + rItem.height - 1
 997			l.renderedItems[item.ID()] = rItem
 998		}
 999
1000		gap := l.gap + 1
1001		if inx == itemsLen-1 {
1002			gap = 0
1003		}
1004
1005		fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
1006
1007		currentContentHeight = rItem.end + 1 + l.gap
1008	}
1009
1010	// second pass: build rendered string efficiently
1011	var b strings.Builder
1012
1013	// Pre-size the builder to reduce allocations
1014	estimatedSize := len(rendered)
1015	for _, f := range fragments {
1016		estimatedSize += len(f.view) + f.gap
1017	}
1018	b.Grow(estimatedSize)
1019
1020	if l.direction == DirectionForward {
1021		b.WriteString(rendered)
1022		for i := range fragments {
1023			f := &fragments[i]
1024			b.WriteString(f.view)
1025			// Optimized gap writing using pre-allocated buffer
1026			if f.gap > 0 {
1027				if f.gap <= maxGapSize {
1028					b.WriteString(newlineBuffer[:f.gap])
1029				} else {
1030					b.WriteString(strings.Repeat("\n", f.gap))
1031				}
1032			}
1033		}
1034
1035		return b.String(), finalIndex
1036	}
1037
1038	// iterate backwards as fragments are in reversed order
1039	for i := len(fragments) - 1; i >= 0; i-- {
1040		f := &fragments[i]
1041		b.WriteString(f.view)
1042		// Optimized gap writing using pre-allocated buffer
1043		if f.gap > 0 {
1044			if f.gap <= maxGapSize {
1045				b.WriteString(newlineBuffer[:f.gap])
1046			} else {
1047				b.WriteString(strings.Repeat("\n", f.gap))
1048			}
1049		}
1050	}
1051	b.WriteString(rendered)
1052
1053	return b.String(), finalIndex
1054}
1055
1056func (l *list[T]) renderItem(item Item) renderedItem {
1057	view := item.View()
1058	return renderedItem{
1059		view:   view,
1060		height: lipgloss.Height(view),
1061	}
1062}
1063
1064// AppendItem implements List.
1065func (l *list[T]) AppendItem(item T) tea.Cmd {
1066	// Pre-allocate with expected capacity
1067	cmds := make([]tea.Cmd, 0, 4)
1068	cmd := item.Init()
1069	if cmd != nil {
1070		cmds = append(cmds, cmd)
1071	}
1072
1073	newIndex := len(l.items)
1074	l.items = append(l.items, item)
1075	l.indexMap[item.ID()] = newIndex
1076
1077	if l.width > 0 && l.height > 0 {
1078		cmd = item.SetSize(l.width, l.height)
1079		if cmd != nil {
1080			cmds = append(cmds, cmd)
1081		}
1082	}
1083	cmd = l.render()
1084	if cmd != nil {
1085		cmds = append(cmds, cmd)
1086	}
1087	if l.direction == DirectionBackward {
1088		if l.offset == 0 {
1089			cmd = l.GoToBottom()
1090			if cmd != nil {
1091				cmds = append(cmds, cmd)
1092			}
1093		} else {
1094			newItem, ok := l.renderedItems[item.ID()]
1095			if ok {
1096				newLines := newItem.height
1097				if len(l.items) > 1 {
1098					newLines += l.gap
1099				}
1100				l.offset = min(l.renderedHeight-1, l.offset+newLines)
1101			}
1102		}
1103	}
1104	return tea.Sequence(cmds...)
1105}
1106
1107// Blur implements List.
1108func (l *list[T]) Blur() tea.Cmd {
1109	l.focused = false
1110	return l.render()
1111}
1112
1113// DeleteItem implements List.
1114func (l *list[T]) DeleteItem(id string) tea.Cmd {
1115	inx, ok := l.indexMap[id]
1116	if !ok {
1117		return nil
1118	}
1119	l.items = append(l.items[:inx], l.items[inx+1:]...)
1120	delete(l.renderedItems, id)
1121	delete(l.indexMap, id)
1122
1123	// Only update indices for items after the deleted one
1124	itemsLen := len(l.items)
1125	for i := inx; i < itemsLen; i++ {
1126		if i >= 0 && i < len(l.items) {
1127			item := l.items[i]
1128			l.indexMap[item.ID()] = i
1129		}
1130	}
1131
1132	// Adjust selectedItemIdx if the deleted item was selected or before it
1133	if l.selectedItemIdx == inx {
1134		// Deleted item was selected, select the previous item if possible
1135		if inx > 0 {
1136			l.selectedItemIdx = inx - 1
1137		} else {
1138			l.selectedItemIdx = -1
1139		}
1140	} else if l.selectedItemIdx > inx {
1141		// Selected item is after the deleted one, shift index down
1142		l.selectedItemIdx--
1143	}
1144	cmd := l.render()
1145	if l.rendered != "" {
1146		if l.renderedHeight <= l.height {
1147			l.offset = 0
1148		} else {
1149			maxOffset := l.renderedHeight - l.height
1150			if l.offset > maxOffset {
1151				l.offset = maxOffset
1152			}
1153		}
1154	}
1155	return cmd
1156}
1157
1158// Focus implements List.
1159func (l *list[T]) Focus() tea.Cmd {
1160	l.focused = true
1161	return l.render()
1162}
1163
1164// GetSize implements List.
1165func (l *list[T]) GetSize() (int, int) {
1166	return l.width, l.height
1167}
1168
1169// GoToBottom implements List.
1170func (l *list[T]) GoToBottom() tea.Cmd {
1171	l.offset = 0
1172	l.selectedItemIdx = -1
1173	l.direction = DirectionBackward
1174	return l.render()
1175}
1176
1177// GoToTop implements List.
1178func (l *list[T]) GoToTop() tea.Cmd {
1179	l.offset = 0
1180	l.selectedItemIdx = -1
1181	l.direction = DirectionForward
1182	return l.render()
1183}
1184
1185// IsFocused implements List.
1186func (l *list[T]) IsFocused() bool {
1187	return l.focused
1188}
1189
1190// Items implements List.
1191func (l *list[T]) Items() []T {
1192	itemsLen := len(l.items)
1193	result := make([]T, 0, itemsLen)
1194	for i := range itemsLen {
1195		if i >= 0 && i < len(l.items) {
1196			item := l.items[i]
1197			result = append(result, item)
1198		}
1199	}
1200	return result
1201}
1202
1203func (l *list[T]) incrementOffset(n int) {
1204	// no need for offset
1205	if l.renderedHeight <= l.height {
1206		return
1207	}
1208	maxOffset := l.renderedHeight - l.height
1209	n = min(n, maxOffset-l.offset)
1210	if n <= 0 {
1211		return
1212	}
1213	l.offset += n
1214	l.cachedViewDirty = true
1215}
1216
1217func (l *list[T]) decrementOffset(n int) {
1218	n = min(n, l.offset)
1219	if n <= 0 {
1220		return
1221	}
1222	l.offset -= n
1223	if l.offset < 0 {
1224		l.offset = 0
1225	}
1226	l.cachedViewDirty = true
1227}
1228
1229// MoveDown implements List.
1230func (l *list[T]) MoveDown(n int) tea.Cmd {
1231	oldOffset := l.offset
1232	if l.direction == DirectionForward {
1233		l.incrementOffset(n)
1234	} else {
1235		l.decrementOffset(n)
1236	}
1237
1238	if oldOffset == l.offset {
1239		// no change in offset, so no need to change selection
1240		return nil
1241	}
1242	// if we are not actively selecting move the whole selection down
1243	if l.hasSelection() && !l.selectionActive {
1244		if l.selectionStartLine < l.selectionEndLine {
1245			l.selectionStartLine -= n
1246			l.selectionEndLine -= n
1247		} else {
1248			l.selectionStartLine -= n
1249			l.selectionEndLine -= n
1250		}
1251	}
1252	if l.selectionActive {
1253		if l.selectionStartLine < l.selectionEndLine {
1254			l.selectionStartLine -= n
1255		} else {
1256			l.selectionEndLine -= n
1257		}
1258	}
1259	return l.changeSelectionWhenScrolling()
1260}
1261
1262// MoveUp implements List.
1263func (l *list[T]) MoveUp(n int) tea.Cmd {
1264	oldOffset := l.offset
1265	if l.direction == DirectionForward {
1266		l.decrementOffset(n)
1267	} else {
1268		l.incrementOffset(n)
1269	}
1270
1271	if oldOffset == l.offset {
1272		// no change in offset, so no need to change selection
1273		return nil
1274	}
1275
1276	if l.hasSelection() && !l.selectionActive {
1277		if l.selectionStartLine > l.selectionEndLine {
1278			l.selectionStartLine += n
1279			l.selectionEndLine += n
1280		} else {
1281			l.selectionStartLine += n
1282			l.selectionEndLine += n
1283		}
1284	}
1285	if l.selectionActive {
1286		if l.selectionStartLine > l.selectionEndLine {
1287			l.selectionStartLine += n
1288		} else {
1289			l.selectionEndLine += n
1290		}
1291	}
1292	return l.changeSelectionWhenScrolling()
1293}
1294
1295// PrependItem implements List.
1296func (l *list[T]) PrependItem(item T) tea.Cmd {
1297	// Pre-allocate with expected capacity
1298	cmds := make([]tea.Cmd, 0, 4)
1299	cmds = append(cmds, item.Init())
1300
1301	l.items = append([]T{item}, l.items...)
1302
1303	// Shift selectedItemIdx since all items moved down by 1
1304	if l.selectedItemIdx >= 0 {
1305		l.selectedItemIdx++
1306	}
1307
1308	// Update index map incrementally: shift all existing indices up by 1
1309	// This is more efficient than rebuilding from scratch
1310	newIndexMap := make(map[string]int, len(l.indexMap)+1)
1311	for id, idx := range l.indexMap {
1312		newIndexMap[id] = idx + 1 // All existing items shift down by 1
1313	}
1314	newIndexMap[item.ID()] = 0 // New item is at index 0
1315	l.indexMap = newIndexMap
1316
1317	if l.width > 0 && l.height > 0 {
1318		cmds = append(cmds, item.SetSize(l.width, l.height))
1319	}
1320	cmds = append(cmds, l.render())
1321	if l.direction == DirectionForward {
1322		if l.offset == 0 {
1323			cmd := l.GoToTop()
1324			if cmd != nil {
1325				cmds = append(cmds, cmd)
1326			}
1327		} else {
1328			newItem, ok := l.renderedItems[item.ID()]
1329			if ok {
1330				newLines := newItem.height
1331				if len(l.items) > 1 {
1332					newLines += l.gap
1333				}
1334				l.offset = min(l.renderedHeight-1, l.offset+newLines)
1335			}
1336		}
1337	}
1338	return tea.Batch(cmds...)
1339}
1340
1341// SelectItemAbove implements List.
1342func (l *list[T]) SelectItemAbove() tea.Cmd {
1343	if l.selectedItemIdx < 0 {
1344		return nil
1345	}
1346
1347	newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
1348	if newIndex == ItemNotFound {
1349		// no item above
1350		return nil
1351	}
1352	// Pre-allocate with expected capacity
1353	cmds := make([]tea.Cmd, 0, 2)
1354	if newIndex == 1 {
1355		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1356		if peakAboveIndex == ItemNotFound {
1357			// this means there is a section above move to the top
1358			cmd := l.GoToTop()
1359			if cmd != nil {
1360				cmds = append(cmds, cmd)
1361			}
1362		}
1363	}
1364	if newIndex < 0 || newIndex >= len(l.items) {
1365		return nil
1366	}
1367	l.prevSelectedItemIdx = l.selectedItemIdx
1368	l.selectedItemIdx = newIndex
1369	l.movingByItem = true
1370	renderCmd := l.render()
1371	if renderCmd != nil {
1372		cmds = append(cmds, renderCmd)
1373	}
1374	return tea.Sequence(cmds...)
1375}
1376
1377// SelectItemBelow implements List.
1378func (l *list[T]) SelectItemBelow() tea.Cmd {
1379	if l.selectedItemIdx < 0 {
1380		return nil
1381	}
1382
1383	newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
1384	if newIndex == ItemNotFound {
1385		// no item above
1386		return nil
1387	}
1388	if newIndex < 0 || newIndex >= len(l.items) {
1389		return nil
1390	}
1391	l.prevSelectedItemIdx = l.selectedItemIdx
1392	l.selectedItemIdx = newIndex
1393	l.movingByItem = true
1394	return l.render()
1395}
1396
1397// SelectedItem implements List.
1398func (l *list[T]) SelectedItem() *T {
1399	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
1400		return nil
1401	}
1402	item := l.items[l.selectedItemIdx]
1403	return &item
1404}
1405
1406// SetItems implements List.
1407func (l *list[T]) SetItems(items []T) tea.Cmd {
1408	l.items = items
1409	var cmds []tea.Cmd
1410	for inx, item := range items {
1411		if i, ok := any(item).(Indexable); ok {
1412			i.SetIndex(inx)
1413		}
1414		cmds = append(cmds, item.Init())
1415	}
1416	cmds = append(cmds, l.reset(""))
1417	return tea.Batch(cmds...)
1418}
1419
1420// SetSelected implements List.
1421func (l *list[T]) SetSelected(id string) tea.Cmd {
1422	l.prevSelectedItemIdx = l.selectedItemIdx
1423	if idx, ok := l.indexMap[id]; ok {
1424		l.selectedItemIdx = idx
1425	} else {
1426		l.selectedItemIdx = -1
1427	}
1428	return l.render()
1429}
1430
1431func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1432	var cmds []tea.Cmd
1433	l.rendered = ""
1434	l.renderedHeight = 0
1435	l.offset = 0
1436	l.indexMap = make(map[string]int)
1437	l.renderedItems = make(map[string]renderedItem)
1438	itemsLen := len(l.items)
1439	for i := range itemsLen {
1440		if i < 0 || i >= len(l.items) {
1441			continue
1442		}
1443
1444		item := l.items[i]
1445		l.indexMap[item.ID()] = i
1446		if l.width > 0 && l.height > 0 {
1447			cmds = append(cmds, item.SetSize(l.width, l.height))
1448		}
1449	}
1450	// Convert selectedItemID to index after rebuilding indexMap
1451	if selectedItemID != "" {
1452		if idx, ok := l.indexMap[selectedItemID]; ok {
1453			l.selectedItemIdx = idx
1454		} else {
1455			l.selectedItemIdx = -1
1456		}
1457	} else {
1458		l.selectedItemIdx = -1
1459	}
1460	cmds = append(cmds, l.render())
1461	return tea.Batch(cmds...)
1462}
1463
1464// SetSize implements List.
1465func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1466	oldWidth := l.width
1467	l.width = width
1468	l.height = height
1469	if oldWidth != width {
1470		// Get current selected item ID before reset
1471		selectedID := ""
1472		if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
1473			item := l.items[l.selectedItemIdx]
1474			selectedID = item.ID()
1475		}
1476		cmd := l.reset(selectedID)
1477		return cmd
1478	}
1479	return nil
1480}
1481
1482// UpdateItem implements List.
1483func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1484	// Pre-allocate with expected capacity
1485	cmds := make([]tea.Cmd, 0, 1)
1486	if inx, ok := l.indexMap[id]; ok {
1487		l.items[inx] = item
1488		oldItem, hasOldItem := l.renderedItems[id]
1489		oldPosition := l.offset
1490		if l.direction == DirectionBackward {
1491			oldPosition = (l.renderedHeight - 1) - l.offset
1492		}
1493
1494		delete(l.renderedItems, id)
1495		cmd := l.render()
1496
1497		// need to check for nil because of sequence not handling nil
1498		if cmd != nil {
1499			cmds = append(cmds, cmd)
1500		}
1501		if hasOldItem && l.direction == DirectionBackward {
1502			// if we are the last item and there is no offset
1503			// make sure to go to the bottom
1504			if oldPosition < oldItem.end {
1505				newItem, ok := l.renderedItems[item.ID()]
1506				if ok {
1507					newLines := newItem.height - oldItem.height
1508					l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1509				}
1510			}
1511		} else if hasOldItem && l.offset > oldItem.start {
1512			newItem, ok := l.renderedItems[item.ID()]
1513			if ok {
1514				newLines := newItem.height - oldItem.height
1515				l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
1516			}
1517		}
1518	}
1519	return tea.Sequence(cmds...)
1520}
1521
1522func (l *list[T]) hasSelection() bool {
1523	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
1524}
1525
1526// StartSelection implements List.
1527func (l *list[T]) StartSelection(col, line int) {
1528	l.selectionStartCol = col
1529	l.selectionStartLine = line
1530	l.selectionEndCol = col
1531	l.selectionEndLine = line
1532	l.selectionActive = true
1533}
1534
1535// EndSelection implements List.
1536func (l *list[T]) EndSelection(col, line int) {
1537	if !l.selectionActive {
1538		return
1539	}
1540	l.selectionEndCol = col
1541	l.selectionEndLine = line
1542}
1543
1544func (l *list[T]) SelectionStop() {
1545	l.selectionActive = false
1546}
1547
1548func (l *list[T]) SelectionClear() {
1549	l.selectionStartCol = -1
1550	l.selectionStartLine = -1
1551	l.selectionEndCol = -1
1552	l.selectionEndLine = -1
1553	l.selectionActive = false
1554}
1555
1556func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1557	numLines := l.lineCount()
1558
1559	if l.direction == DirectionBackward && numLines > l.height {
1560		line = ((numLines - 1) - l.height) + line + 1
1561	}
1562
1563	if l.offset > 0 {
1564		if l.direction == DirectionBackward {
1565			line -= l.offset
1566		} else {
1567			line += l.offset
1568		}
1569	}
1570
1571	if line < 0 || line >= numLines {
1572		return 0, 0
1573	}
1574
1575	currentLine := ansi.Strip(l.getLine(line))
1576	gr := uniseg.NewGraphemes(currentLine)
1577	startCol = -1
1578	upTo := col
1579	for gr.Next() {
1580		if gr.IsWordBoundary() && upTo > 0 {
1581			startCol = col - upTo + 1
1582		} else if gr.IsWordBoundary() && upTo < 0 {
1583			endCol = col - upTo + 1
1584			break
1585		}
1586		if upTo == 0 && gr.Str() == " " {
1587			return 0, 0
1588		}
1589		upTo -= 1
1590	}
1591	if startCol == -1 {
1592		return 0, 0
1593	}
1594	return startCol, endCol
1595}
1596
1597func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1598	// Helper function to get a line with ANSI stripped and icons replaced
1599	getCleanLine := func(index int) string {
1600		rawLine := l.getLine(index)
1601		cleanLine := ansi.Strip(rawLine)
1602		for _, icon := range styles.SelectionIgnoreIcons {
1603			cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
1604		}
1605		return cleanLine
1606	}
1607
1608	numLines := l.lineCount()
1609	if l.direction == DirectionBackward && numLines > l.height {
1610		line = (numLines - 1) - l.height + line + 1
1611	}
1612
1613	if l.offset > 0 {
1614		if l.direction == DirectionBackward {
1615			line -= l.offset
1616		} else {
1617			line += l.offset
1618		}
1619	}
1620
1621	// Ensure line is within bounds
1622	if line < 0 || line >= numLines {
1623		return 0, 0, false
1624	}
1625
1626	if strings.TrimSpace(getCleanLine(line)) == "" {
1627		return 0, 0, false
1628	}
1629
1630	// Find start of paragraph (search backwards for empty line or start of text)
1631	startLine = line
1632	for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
1633		startLine--
1634	}
1635
1636	// Find end of paragraph (search forwards for empty line or end of text)
1637	endLine = line
1638	for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
1639		endLine++
1640	}
1641
1642	// revert the line numbers if we are in backward direction
1643	if l.direction == DirectionBackward && numLines > l.height {
1644		startLine = startLine - (numLines - 1) + l.height - 1
1645		endLine = endLine - (numLines - 1) + l.height - 1
1646	}
1647	if l.offset > 0 {
1648		if l.direction == DirectionBackward {
1649			startLine += l.offset
1650			endLine += l.offset
1651		} else {
1652			startLine -= l.offset
1653			endLine -= l.offset
1654		}
1655	}
1656	return startLine, endLine, true
1657}
1658
1659// SelectWord selects the word at the given position.
1660func (l *list[T]) SelectWord(col, line int) {
1661	startCol, endCol := l.findWordBoundaries(col, line)
1662	l.selectionStartCol = startCol
1663	l.selectionStartLine = line
1664	l.selectionEndCol = endCol
1665	l.selectionEndLine = line
1666	l.selectionActive = false // Not actively selecting, just selected
1667}
1668
1669// SelectParagraph selects the paragraph at the given position.
1670func (l *list[T]) SelectParagraph(col, line int) {
1671	startLine, endLine, found := l.findParagraphBoundaries(line)
1672	if !found {
1673		return
1674	}
1675	l.selectionStartCol = 0
1676	l.selectionStartLine = startLine
1677	l.selectionEndCol = l.width - 1
1678	l.selectionEndLine = endLine
1679	l.selectionActive = false // Not actively selecting, just selected
1680}
1681
1682// HasSelection returns whether there is an active selection.
1683func (l *list[T]) HasSelection() bool {
1684	return l.hasSelection()
1685}
1686
1687// GetSelectedText returns the currently selected text.
1688func (l *list[T]) GetSelectedText(paddingLeft int) string {
1689	if !l.hasSelection() {
1690		return ""
1691	}
1692
1693	return l.selectionView(l.View(), true)
1694}