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 := l.selectionArea(false)
 354	specialChars := getSpecialCharsMap()
 355	selStyle := uv.Style{
 356		Bg: t.TextSelection.GetBackground(),
 357		Fg: t.TextSelection.GetForeground(),
 358	}
 359
 360	isNonWhitespace := func(r rune) bool {
 361		return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
 362	}
 363
 364	type selectionBounds struct {
 365		startX, endX int
 366		inSelection  bool
 367	}
 368	lineSelections := make([]selectionBounds, scr.Height())
 369
 370	for y := range scr.Height() {
 371		bounds := selectionBounds{startX: -1, endX: -1, inSelection: false}
 372
 373		if y >= selArea.Min.Y && y < selArea.Max.Y {
 374			bounds.inSelection = true
 375			if selArea.Min.Y == selArea.Max.Y-1 {
 376				// Single line selection
 377				bounds.startX = selArea.Min.X
 378				bounds.endX = selArea.Max.X
 379			} else if y == selArea.Min.Y {
 380				// First line of multi-line selection
 381				bounds.startX = selArea.Min.X
 382				bounds.endX = scr.Width()
 383			} else if y == selArea.Max.Y-1 {
 384				// Last line of multi-line selection
 385				bounds.startX = 0
 386				bounds.endX = selArea.Max.X
 387			} else {
 388				// Middle lines
 389				bounds.startX = 0
 390				bounds.endX = scr.Width()
 391			}
 392		}
 393		lineSelections[y] = bounds
 394	}
 395
 396	type lineBounds struct {
 397		start, end int
 398	}
 399	lineTextBounds := make([]lineBounds, scr.Height())
 400
 401	// First pass: find text bounds for lines that have selections
 402	for y := range scr.Height() {
 403		bounds := lineBounds{start: -1, end: -1}
 404
 405		// Only process lines that might have selections
 406		if lineSelections[y].inSelection {
 407			for x := range scr.Width() {
 408				cell := scr.CellAt(x, y)
 409				if cell == nil {
 410					continue
 411				}
 412
 413				cellStr := cell.String()
 414				if len(cellStr) == 0 {
 415					continue
 416				}
 417
 418				char := rune(cellStr[0])
 419				_, isSpecial := specialChars[cellStr]
 420
 421				if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
 422					if bounds.start == -1 {
 423						bounds.start = x
 424					}
 425					bounds.end = x + 1 // Position after last character
 426				}
 427			}
 428		}
 429		lineTextBounds[y] = bounds
 430	}
 431
 432	var selectedText strings.Builder
 433
 434	// Second pass: apply selection highlighting
 435	for y := range scr.Height() {
 436		selBounds := lineSelections[y]
 437		if !selBounds.inSelection {
 438			continue
 439		}
 440
 441		textBounds := lineTextBounds[y]
 442		if textBounds.start < 0 {
 443			if textOnly {
 444				// We don't want to get rid of all empty lines in text-only mode
 445				selectedText.WriteByte('\n')
 446			}
 447
 448			continue // No text on this line
 449		}
 450
 451		// Only scan within the intersection of text bounds and selection bounds
 452		scanStart := max(textBounds.start, selBounds.startX)
 453		scanEnd := min(textBounds.end, selBounds.endX)
 454
 455		for x := scanStart; x < scanEnd; x++ {
 456			cell := scr.CellAt(x, y)
 457			if cell == nil {
 458				continue
 459			}
 460
 461			cellStr := cell.String()
 462			if len(cellStr) > 0 {
 463				if _, isSpecial := specialChars[cellStr]; isSpecial {
 464					continue
 465				}
 466				if textOnly {
 467					// Collect selected text without styles
 468					selectedText.WriteString(cell.String())
 469					continue
 470				}
 471
 472				cell = cell.Clone()
 473				cell.Style.Bg = selStyle.Bg
 474				cell.Style.Fg = selStyle.Fg
 475				scr.SetCell(x, y, cell)
 476			}
 477		}
 478
 479		if textOnly {
 480			// Make sure we add a newline after each line of selected text
 481			selectedText.WriteByte('\n')
 482		}
 483	}
 484
 485	if textOnly {
 486		return strings.TrimSpace(selectedText.String())
 487	}
 488
 489	return scr.Render()
 490}
 491
 492func (l *list[T]) View() string {
 493	if l.height <= 0 || l.width <= 0 {
 494		return ""
 495	}
 496
 497	if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
 498		return l.cachedView
 499	}
 500
 501	t := styles.CurrentTheme()
 502
 503	start, end := l.viewPosition()
 504	viewStart := max(0, start)
 505	viewEnd := end
 506
 507	if viewStart > viewEnd {
 508		return ""
 509	}
 510
 511	view := l.getLines(viewStart, viewEnd)
 512
 513	if l.resize {
 514		return view
 515	}
 516
 517	view = t.S().Base.
 518		Height(l.height).
 519		Width(l.width).
 520		Render(view)
 521
 522	if !l.hasSelection() {
 523		l.cachedView = view
 524		l.cachedViewOffset = l.offset
 525		l.cachedViewDirty = false
 526		return view
 527	}
 528
 529	return l.selectionView(view, false)
 530}
 531
 532func (l *list[T]) viewPosition() (int, int) {
 533	start, end := 0, 0
 534	renderedLines := l.renderedHeight - 1
 535	if l.direction == DirectionForward {
 536		start = max(0, l.offset)
 537		end = min(l.offset+l.height-1, renderedLines)
 538	} else {
 539		start = max(0, renderedLines-l.offset-l.height+1)
 540		end = max(0, renderedLines-l.offset)
 541	}
 542	start = min(start, end)
 543	return start, end
 544}
 545
 546func (l *list[T]) setRendered(rendered string) {
 547	l.rendered = rendered
 548	l.renderedHeight = lipgloss.Height(rendered)
 549	l.cachedViewDirty = true // Mark view cache as dirty
 550
 551	if len(rendered) > 0 {
 552		l.lineOffsets = make([]int, 0, l.renderedHeight)
 553		l.lineOffsets = append(l.lineOffsets, 0)
 554
 555		offset := 0
 556		for {
 557			idx := strings.IndexByte(rendered[offset:], '\n')
 558			if idx == -1 {
 559				break
 560			}
 561			offset += idx + 1
 562			l.lineOffsets = append(l.lineOffsets, offset)
 563		}
 564	} else {
 565		l.lineOffsets = nil
 566	}
 567}
 568
 569func (l *list[T]) getLines(start, end int) string {
 570	if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
 571		return ""
 572	}
 573
 574	if end >= len(l.lineOffsets) {
 575		end = len(l.lineOffsets) - 1
 576	}
 577	if start > end {
 578		return ""
 579	}
 580
 581	startOffset := l.lineOffsets[start]
 582	var endOffset int
 583	if end+1 < len(l.lineOffsets) {
 584		endOffset = l.lineOffsets[end+1] - 1
 585	} else {
 586		endOffset = len(l.rendered)
 587	}
 588
 589	if startOffset >= len(l.rendered) {
 590		return ""
 591	}
 592	endOffset = min(endOffset, len(l.rendered))
 593
 594	return l.rendered[startOffset:endOffset]
 595}
 596
 597// getLine returns a single line from the rendered content using lineOffsets.
 598// This avoids allocating a new string for each line like strings.Split does.
 599func (l *list[T]) getLine(index int) string {
 600	if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
 601		return ""
 602	}
 603
 604	startOffset := l.lineOffsets[index]
 605	var endOffset int
 606	if index+1 < len(l.lineOffsets) {
 607		endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
 608	} else {
 609		endOffset = len(l.rendered)
 610	}
 611
 612	if startOffset >= len(l.rendered) {
 613		return ""
 614	}
 615	endOffset = min(endOffset, len(l.rendered))
 616
 617	return l.rendered[startOffset:endOffset]
 618}
 619
 620// lineCount returns the number of lines in the rendered content.
 621func (l *list[T]) lineCount() int {
 622	return len(l.lineOffsets)
 623}
 624
 625func (l *list[T]) recalculateItemPositions() {
 626	l.recalculateItemPositionsFrom(0)
 627}
 628
 629func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
 630	var currentContentHeight int
 631
 632	if startIdx > 0 && startIdx <= len(l.items) {
 633		prevItem := l.items[startIdx-1]
 634		if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
 635			currentContentHeight = rItem.end + 1 + l.gap
 636		}
 637	}
 638
 639	for i := startIdx; i < len(l.items); i++ {
 640		item := l.items[i]
 641		rItem, ok := l.renderedItems[item.ID()]
 642		if !ok {
 643			continue
 644		}
 645		rItem.start = currentContentHeight
 646		rItem.end = currentContentHeight + rItem.height - 1
 647		l.renderedItems[item.ID()] = rItem
 648		currentContentHeight = rItem.end + 1 + l.gap
 649	}
 650}
 651
 652func (l *list[T]) render() tea.Cmd {
 653	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
 654		return nil
 655	}
 656	l.setDefaultSelected()
 657
 658	var focusChangeCmd tea.Cmd
 659	if l.focused {
 660		focusChangeCmd = l.focusSelectedItem()
 661	} else {
 662		focusChangeCmd = l.blurSelectedItem()
 663	}
 664	if l.rendered != "" {
 665		rendered, _ := l.renderIterator(0, false, "")
 666		l.setRendered(rendered)
 667		if l.direction == DirectionBackward {
 668			l.recalculateItemPositions()
 669		}
 670		if l.focused {
 671			l.scrollToSelection()
 672		}
 673		return focusChangeCmd
 674	}
 675	rendered, finishIndex := l.renderIterator(0, true, "")
 676	l.setRendered(rendered)
 677	if l.direction == DirectionBackward {
 678		l.recalculateItemPositions()
 679	}
 680
 681	l.offset = 0
 682	rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
 683	l.setRendered(rendered)
 684	if l.direction == DirectionBackward {
 685		l.recalculateItemPositions()
 686	}
 687	if l.focused {
 688		l.scrollToSelection()
 689	}
 690
 691	return focusChangeCmd
 692}
 693
 694func (l *list[T]) setDefaultSelected() {
 695	if l.selectedItemIdx < 0 {
 696		if l.direction == DirectionForward {
 697			l.selectFirstItem()
 698		} else {
 699			l.selectLastItem()
 700		}
 701	}
 702}
 703
 704func (l *list[T]) scrollToSelection() {
 705	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
 706		l.selectedItemIdx = -1
 707		l.setDefaultSelected()
 708		return
 709	}
 710	item := l.items[l.selectedItemIdx]
 711	rItem, ok := l.renderedItems[item.ID()]
 712	if !ok {
 713		l.selectedItemIdx = -1
 714		l.setDefaultSelected()
 715		return
 716	}
 717
 718	start, end := l.viewPosition()
 719	if rItem.start <= start && rItem.end >= end {
 720		return
 721	}
 722	if l.movingByItem {
 723		if rItem.start >= start && rItem.end <= end {
 724			return
 725		}
 726		defer func() { l.movingByItem = false }()
 727	} else {
 728		if rItem.start >= start && rItem.start <= end {
 729			return
 730		}
 731		if rItem.end >= start && rItem.end <= end {
 732			return
 733		}
 734	}
 735
 736	if rItem.height >= l.height {
 737		if l.direction == DirectionForward {
 738			l.offset = rItem.start
 739		} else {
 740			l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
 741		}
 742		return
 743	}
 744
 745	renderedLines := l.renderedHeight - 1
 746
 747	if rItem.start < start {
 748		if l.direction == DirectionForward {
 749			l.offset = rItem.start
 750		} else {
 751			l.offset = max(0, renderedLines-rItem.start-l.height+1)
 752		}
 753	} else if rItem.end > end {
 754		if l.direction == DirectionForward {
 755			l.offset = max(0, rItem.end-l.height+1)
 756		} else {
 757			l.offset = max(0, renderedLines-rItem.end)
 758		}
 759	}
 760}
 761
 762func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 763	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
 764		return nil
 765	}
 766	item := l.items[l.selectedItemIdx]
 767	rItem, ok := l.renderedItems[item.ID()]
 768	if !ok {
 769		return nil
 770	}
 771	start, end := l.viewPosition()
 772	// item bigger than the viewport do nothing
 773	if rItem.start <= start && rItem.end >= end {
 774		return nil
 775	}
 776	// item already in view do nothing
 777	if rItem.start >= start && rItem.end <= end {
 778		return nil
 779	}
 780
 781	itemMiddle := rItem.start + rItem.height/2
 782
 783	if itemMiddle < start {
 784		// select the first item in the viewport
 785		// the item is most likely an item coming after this item
 786		inx := l.selectedItemIdx
 787		for {
 788			inx = l.firstSelectableItemBelow(inx)
 789			if inx == ItemNotFound {
 790				return nil
 791			}
 792			if inx < 0 || inx >= len(l.items) {
 793				continue
 794			}
 795
 796			item := l.items[inx]
 797			renderedItem, ok := l.renderedItems[item.ID()]
 798			if !ok {
 799				continue
 800			}
 801
 802			// If the item is bigger than the viewport, select it
 803			if renderedItem.start <= start && renderedItem.end >= end {
 804				l.selectedItemIdx = inx
 805				return l.render()
 806			}
 807			// item is in the view
 808			if renderedItem.start >= start && renderedItem.start <= end {
 809				l.selectedItemIdx = inx
 810				return l.render()
 811			}
 812		}
 813	} else if itemMiddle > end {
 814		// select the first item in the viewport
 815		// the item is most likely an item coming after this item
 816		inx := l.selectedItemIdx
 817		for {
 818			inx = l.firstSelectableItemAbove(inx)
 819			if inx == ItemNotFound {
 820				return nil
 821			}
 822			if inx < 0 || inx >= len(l.items) {
 823				continue
 824			}
 825
 826			item := l.items[inx]
 827			renderedItem, ok := l.renderedItems[item.ID()]
 828			if !ok {
 829				continue
 830			}
 831
 832			// If the item is bigger than the viewport, select it
 833			if renderedItem.start <= start && renderedItem.end >= end {
 834				l.selectedItemIdx = inx
 835				return l.render()
 836			}
 837			// item is in the view
 838			if renderedItem.end >= start && renderedItem.end <= end {
 839				l.selectedItemIdx = inx
 840				return l.render()
 841			}
 842		}
 843	}
 844	return nil
 845}
 846
 847func (l *list[T]) selectFirstItem() {
 848	inx := l.firstSelectableItemBelow(-1)
 849	if inx != ItemNotFound {
 850		l.selectedItemIdx = inx
 851	}
 852}
 853
 854func (l *list[T]) selectLastItem() {
 855	inx := l.firstSelectableItemAbove(len(l.items))
 856	if inx != ItemNotFound {
 857		l.selectedItemIdx = inx
 858	}
 859}
 860
 861func (l *list[T]) firstSelectableItemAbove(inx int) int {
 862	unfocusableCount := 0
 863	for i := inx - 1; i >= 0; i-- {
 864		if i < 0 || i >= len(l.items) {
 865			continue
 866		}
 867
 868		item := l.items[i]
 869		if _, ok := any(item).(layout.Focusable); ok {
 870			return i
 871		}
 872		unfocusableCount++
 873	}
 874	if unfocusableCount == inx && l.wrap {
 875		return l.firstSelectableItemAbove(len(l.items))
 876	}
 877	return ItemNotFound
 878}
 879
 880func (l *list[T]) firstSelectableItemBelow(inx int) int {
 881	unfocusableCount := 0
 882	itemsLen := len(l.items)
 883	for i := inx + 1; i < itemsLen; i++ {
 884		if i < 0 || i >= len(l.items) {
 885			continue
 886		}
 887
 888		item := l.items[i]
 889		if _, ok := any(item).(layout.Focusable); ok {
 890			return i
 891		}
 892		unfocusableCount++
 893	}
 894	if unfocusableCount == itemsLen-inx-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 > l.selectedItemIdx && l.selectedItemIdx > 0 && l.offset > 0 {
1355		// this means there is a section above and not showing on the top, move to the top
1356		newIndex = l.selectedItemIdx
1357		cmd := l.GoToTop()
1358		if cmd != nil {
1359			cmds = append(cmds, cmd)
1360		}
1361	}
1362	if newIndex == 1 {
1363		peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1364		if peakAboveIndex == ItemNotFound {
1365			// this means there is a section above move to the top
1366			cmd := l.GoToTop()
1367			if cmd != nil {
1368				cmds = append(cmds, cmd)
1369			}
1370		}
1371	}
1372	if newIndex < 0 || newIndex >= len(l.items) {
1373		return nil
1374	}
1375	l.prevSelectedItemIdx = l.selectedItemIdx
1376	l.selectedItemIdx = newIndex
1377	l.movingByItem = true
1378	renderCmd := l.render()
1379	if renderCmd != nil {
1380		cmds = append(cmds, renderCmd)
1381	}
1382	return tea.Sequence(cmds...)
1383}
1384
1385// SelectItemBelow implements List.
1386func (l *list[T]) SelectItemBelow() tea.Cmd {
1387	if l.selectedItemIdx < 0 {
1388		return nil
1389	}
1390
1391	newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
1392	if newIndex == ItemNotFound {
1393		// no item below
1394		return nil
1395	}
1396	if newIndex < 0 || newIndex >= len(l.items) {
1397		return nil
1398	}
1399	if newIndex < l.selectedItemIdx {
1400		// reset offset when wrap to the top to show the top section if it exists
1401		l.offset = 0
1402	}
1403	l.prevSelectedItemIdx = l.selectedItemIdx
1404	l.selectedItemIdx = newIndex
1405	l.movingByItem = true
1406	return l.render()
1407}
1408
1409// SelectedItem implements List.
1410func (l *list[T]) SelectedItem() *T {
1411	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
1412		return nil
1413	}
1414	item := l.items[l.selectedItemIdx]
1415	return &item
1416}
1417
1418// SetItems implements List.
1419func (l *list[T]) SetItems(items []T) tea.Cmd {
1420	l.items = items
1421	var cmds []tea.Cmd
1422	for inx, item := range items {
1423		if i, ok := any(item).(Indexable); ok {
1424			i.SetIndex(inx)
1425		}
1426		cmds = append(cmds, item.Init())
1427	}
1428	cmds = append(cmds, l.reset(""))
1429	return tea.Batch(cmds...)
1430}
1431
1432// SetSelected implements List.
1433func (l *list[T]) SetSelected(id string) tea.Cmd {
1434	l.prevSelectedItemIdx = l.selectedItemIdx
1435	if idx, ok := l.indexMap[id]; ok {
1436		l.selectedItemIdx = idx
1437	} else {
1438		l.selectedItemIdx = -1
1439	}
1440	return l.render()
1441}
1442
1443func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1444	var cmds []tea.Cmd
1445	l.rendered = ""
1446	l.renderedHeight = 0
1447	l.offset = 0
1448	l.indexMap = make(map[string]int)
1449	l.renderedItems = make(map[string]renderedItem)
1450	itemsLen := len(l.items)
1451	for i := range itemsLen {
1452		if i < 0 || i >= len(l.items) {
1453			continue
1454		}
1455
1456		item := l.items[i]
1457		l.indexMap[item.ID()] = i
1458		if l.width > 0 && l.height > 0 {
1459			cmds = append(cmds, item.SetSize(l.width, l.height))
1460		}
1461	}
1462	// Convert selectedItemID to index after rebuilding indexMap
1463	if selectedItemID != "" {
1464		if idx, ok := l.indexMap[selectedItemID]; ok {
1465			l.selectedItemIdx = idx
1466		} else {
1467			l.selectedItemIdx = -1
1468		}
1469	} else {
1470		l.selectedItemIdx = -1
1471	}
1472	cmds = append(cmds, l.render())
1473	return tea.Batch(cmds...)
1474}
1475
1476// SetSize implements List.
1477func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1478	oldWidth := l.width
1479	oldHeight := l.height
1480	l.width = width
1481	l.height = height
1482	// Invalidate cache if height changed
1483	if oldHeight != height {
1484		l.cachedViewDirty = true
1485	}
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
1704func (l *list[T]) selectionArea(absolute bool) uv.Rectangle {
1705	var startY int
1706	if absolute {
1707		startY, _ = l.viewPosition()
1708	}
1709	selArea := uv.Rectangle{
1710		Min: uv.Pos(l.selectionStartCol, l.selectionStartLine+startY),
1711		Max: uv.Pos(l.selectionEndCol, l.selectionEndLine+startY),
1712	}
1713	selArea = selArea.Canon()
1714	selArea.Max.Y++ // make max Y exclusive
1715	return selArea
1716}
1717
1718// GetSelectedText returns the currently selected text.
1719func (l *list[T]) GetSelectedText(paddingLeft int) string {
1720	if !l.hasSelection() {
1721		return ""
1722	}
1723
1724	selArea := l.selectionArea(true)
1725	if selArea.Empty() {
1726		return ""
1727	}
1728
1729	selectionHeight := selArea.Dy()
1730
1731	tempBuf := uv.NewScreenBuffer(l.width, selectionHeight)
1732	tempBufArea := tempBuf.Bounds()
1733	renderedLines := l.getLines(selArea.Min.Y, selArea.Max.Y)
1734	styled := uv.NewStyledString(renderedLines)
1735	styled.Draw(tempBuf, tempBufArea)
1736
1737	// XXX: Left padding assumes the list component is rendered with absolute
1738	// positioning. The chat component has a left margin of 1 and items in the
1739	// list have a border of 1 plus a padding of 1. The paddingLeft parameter
1740	// assumes this total left padding of 3 and we should fix that.
1741	leftBorder := paddingLeft - 1
1742
1743	var b strings.Builder
1744	for y := tempBufArea.Min.Y; y < tempBufArea.Max.Y; y++ {
1745		var pending strings.Builder
1746		for x := tempBufArea.Min.X + leftBorder; x < tempBufArea.Max.X; {
1747			cell := tempBuf.CellAt(x, y)
1748			if cell == nil || cell.IsZero() {
1749				x++
1750				continue
1751			}
1752			if y == 0 && x < selArea.Min.X {
1753				x++
1754				continue
1755			}
1756			if y == selectionHeight-1 && x > selArea.Max.X-1 {
1757				break
1758			}
1759			if cell.Width == 1 && cell.Content == " " {
1760				pending.WriteString(cell.Content)
1761				x++
1762				continue
1763			}
1764			b.WriteString(pending.String())
1765			pending.Reset()
1766			b.WriteString(cell.Content)
1767			x += cell.Width
1768		}
1769		if y < tempBufArea.Max.Y-1 {
1770			b.WriteByte('\n')
1771		}
1772	}
1773
1774	return b.String()
1775}