list.go

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