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