lazylist.go

   1package list
   2
   3import (
   4	"strings"
   5
   6	uv "github.com/charmbracelet/ultraviolet"
   7	"github.com/charmbracelet/ultraviolet/screen"
   8)
   9
  10// LazyList is a virtual scrolling list that only renders visible items.
  11// It uses height estimates to avoid expensive renders during initial layout.
  12type LazyList struct {
  13	// Configuration
  14	width, height int
  15
  16	// Data
  17	items []Item
  18
  19	// Focus & Selection
  20	focused     bool
  21	selectedIdx int // Currently selected item index (-1 if none)
  22
  23	// Item positioning - tracks measured and estimated positions
  24	itemHeights []itemHeight
  25	totalHeight int // Sum of all item heights (measured or estimated)
  26
  27	// Viewport state
  28	offset int // Scroll offset in lines from top
  29
  30	// Rendered items cache - only visible items are rendered
  31	renderedCache map[int]*renderedItemCache
  32
  33	// Virtual scrolling configuration
  34	defaultEstimate int // Default height estimate for unmeasured items
  35	overscan        int // Number of items to render outside viewport for smooth scrolling
  36
  37	// Dirty tracking
  38	needsLayout   bool
  39	dirtyItems    map[int]bool
  40	dirtyViewport bool // True if we need to re-render viewport
  41
  42	// Mouse state
  43	mouseDown     bool
  44	mouseDownItem int
  45	mouseDownX    int
  46	mouseDownY    int
  47	mouseDragItem int
  48	mouseDragX    int
  49	mouseDragY    int
  50}
  51
  52// itemHeight tracks the height of an item - either measured or estimated.
  53type itemHeight struct {
  54	height   int
  55	measured bool // true if height is actual measurement, false if estimate
  56}
  57
  58// renderedItemCache stores a rendered item's buffer.
  59type renderedItemCache struct {
  60	buffer *uv.ScreenBuffer
  61	height int // Actual measured height after rendering
  62}
  63
  64// NewLazyList creates a new lazy-rendering list.
  65func NewLazyList(items ...Item) *LazyList {
  66	l := &LazyList{
  67		items:           items,
  68		itemHeights:     make([]itemHeight, len(items)),
  69		renderedCache:   make(map[int]*renderedItemCache),
  70		dirtyItems:      make(map[int]bool),
  71		selectedIdx:     -1,
  72		mouseDownItem:   -1,
  73		mouseDragItem:   -1,
  74		defaultEstimate: 10, // Conservative estimate: 5 lines per item
  75		overscan:        5,  // Render 3 items above/below viewport
  76		needsLayout:     true,
  77		dirtyViewport:   true,
  78	}
  79
  80	// Initialize all items with estimated heights
  81	for i := range l.items {
  82		l.itemHeights[i] = itemHeight{
  83			height:   l.defaultEstimate,
  84			measured: false,
  85		}
  86	}
  87	l.calculateTotalHeight()
  88
  89	return l
  90}
  91
  92// calculateTotalHeight sums all item heights (measured or estimated).
  93func (l *LazyList) calculateTotalHeight() {
  94	l.totalHeight = 0
  95	for _, h := range l.itemHeights {
  96		l.totalHeight += h.height
  97	}
  98}
  99
 100// getItemPosition returns the Y position where an item starts.
 101func (l *LazyList) getItemPosition(idx int) int {
 102	pos := 0
 103	for i := 0; i < idx && i < len(l.itemHeights); i++ {
 104		pos += l.itemHeights[i].height
 105	}
 106	return pos
 107}
 108
 109// findVisibleItems returns the range of items that are visible or near the viewport.
 110func (l *LazyList) findVisibleItems() (firstIdx, lastIdx int) {
 111	if len(l.items) == 0 {
 112		return 0, 0
 113	}
 114
 115	viewportStart := l.offset
 116	viewportEnd := l.offset + l.height
 117
 118	// Find first visible item
 119	firstIdx = -1
 120	pos := 0
 121	for i := 0; i < len(l.items); i++ {
 122		itemEnd := pos + l.itemHeights[i].height
 123		if itemEnd > viewportStart {
 124			firstIdx = i
 125			break
 126		}
 127		pos = itemEnd
 128	}
 129
 130	// Apply overscan above
 131	firstIdx = max(0, firstIdx-l.overscan)
 132
 133	// Find last visible item
 134	lastIdx = firstIdx
 135	pos = l.getItemPosition(firstIdx)
 136	for i := firstIdx; i < len(l.items); i++ {
 137		if pos >= viewportEnd {
 138			break
 139		}
 140		pos += l.itemHeights[i].height
 141		lastIdx = i
 142	}
 143
 144	// Apply overscan below
 145	lastIdx = min(len(l.items)-1, lastIdx+l.overscan)
 146
 147	return firstIdx, lastIdx
 148}
 149
 150// renderItem renders a single item and caches it.
 151// Returns the actual measured height.
 152func (l *LazyList) renderItem(idx int) int {
 153	if idx < 0 || idx >= len(l.items) {
 154		return 0
 155	}
 156
 157	item := l.items[idx]
 158
 159	// Measure actual height
 160	actualHeight := item.Height(l.width)
 161
 162	// Create buffer and render
 163	buf := uv.NewScreenBuffer(l.width, actualHeight)
 164	area := uv.Rect(0, 0, l.width, actualHeight)
 165	item.Draw(&buf, area)
 166
 167	// Cache rendered item
 168	l.renderedCache[idx] = &renderedItemCache{
 169		buffer: &buf,
 170		height: actualHeight,
 171	}
 172
 173	// Update height if it was estimated or changed
 174	if !l.itemHeights[idx].measured || l.itemHeights[idx].height != actualHeight {
 175		oldHeight := l.itemHeights[idx].height
 176		l.itemHeights[idx] = itemHeight{
 177			height:   actualHeight,
 178			measured: true,
 179		}
 180
 181		// Adjust total height
 182		l.totalHeight += actualHeight - oldHeight
 183	}
 184
 185	return actualHeight
 186}
 187
 188// Draw implements uv.Drawable.
 189func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) {
 190	if area.Dx() <= 0 || area.Dy() <= 0 {
 191		return
 192	}
 193
 194	widthChanged := l.width != area.Dx()
 195	heightChanged := l.height != area.Dy()
 196
 197	l.width = area.Dx()
 198	l.height = area.Dy()
 199
 200	// Width changes invalidate all cached renders
 201	if widthChanged {
 202		l.renderedCache = make(map[int]*renderedItemCache)
 203		// Mark all heights as needing remeasurement
 204		for i := range l.itemHeights {
 205			l.itemHeights[i].measured = false
 206			l.itemHeights[i].height = l.defaultEstimate
 207		}
 208		l.calculateTotalHeight()
 209		l.needsLayout = true
 210		l.dirtyViewport = true
 211	}
 212
 213	if heightChanged {
 214		l.clampOffset()
 215		l.dirtyViewport = true
 216	}
 217
 218	if len(l.items) == 0 {
 219		screen.ClearArea(scr, area)
 220		return
 221	}
 222
 223	// Find visible items based on current estimates
 224	firstIdx, lastIdx := l.findVisibleItems()
 225
 226	// Track the first visible item's position to maintain stability
 227	// Only stabilize if we're not at the top boundary
 228	stabilizeIdx := -1
 229	stabilizeY := 0
 230	if l.offset > 0 {
 231		for i := firstIdx; i <= lastIdx; i++ {
 232			itemPos := l.getItemPosition(i)
 233			if itemPos >= l.offset {
 234				stabilizeIdx = i
 235				stabilizeY = itemPos
 236				break
 237			}
 238		}
 239	}
 240
 241	// Track if any heights changed during rendering
 242	heightsChanged := false
 243
 244	// Render visible items that aren't cached (measurement pass)
 245	for i := firstIdx; i <= lastIdx; i++ {
 246		if _, cached := l.renderedCache[i]; !cached {
 247			oldHeight := l.itemHeights[i].height
 248			l.renderItem(i)
 249			if l.itemHeights[i].height != oldHeight {
 250				heightsChanged = true
 251			}
 252		} else if l.dirtyItems[i] {
 253			// Re-render dirty items
 254			oldHeight := l.itemHeights[i].height
 255			l.renderItem(i)
 256			delete(l.dirtyItems, i)
 257			if l.itemHeights[i].height != oldHeight {
 258				heightsChanged = true
 259			}
 260		}
 261	}
 262
 263	// If heights changed, adjust offset to keep stabilization point stable
 264	if heightsChanged && stabilizeIdx >= 0 {
 265		newStabilizeY := l.getItemPosition(stabilizeIdx)
 266		offsetDelta := newStabilizeY - stabilizeY
 267
 268		// Adjust offset to maintain visual stability
 269		l.offset += offsetDelta
 270		l.clampOffset()
 271
 272		// Re-find visible items with adjusted positions
 273		firstIdx, lastIdx = l.findVisibleItems()
 274
 275		// Render any newly visible items after position adjustments
 276		for i := firstIdx; i <= lastIdx; i++ {
 277			if _, cached := l.renderedCache[i]; !cached {
 278				l.renderItem(i)
 279			}
 280		}
 281	}
 282
 283	// Clear old cache entries outside visible range
 284	if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 {
 285		l.pruneCache(firstIdx, lastIdx)
 286	}
 287
 288	// Composite visible items into viewport with stable positions
 289	l.drawViewport(scr, area, firstIdx, lastIdx)
 290
 291	l.dirtyViewport = false
 292	l.needsLayout = false
 293}
 294
 295// drawViewport composites visible items into the screen.
 296func (l *LazyList) drawViewport(scr uv.Screen, area uv.Rectangle, firstIdx, lastIdx int) {
 297	screen.ClearArea(scr, area)
 298
 299	itemStartY := l.getItemPosition(firstIdx)
 300
 301	for i := firstIdx; i <= lastIdx; i++ {
 302		cached, ok := l.renderedCache[i]
 303		if !ok {
 304			continue
 305		}
 306
 307		// Calculate where this item appears in viewport
 308		itemY := itemStartY - l.offset
 309		itemHeight := cached.height
 310
 311		// Skip if entirely above viewport
 312		if itemY+itemHeight < 0 {
 313			itemStartY += itemHeight
 314			continue
 315		}
 316
 317		// Stop if entirely below viewport
 318		if itemY >= l.height {
 319			break
 320		}
 321
 322		// Calculate visible portion of item
 323		srcStartY := 0
 324		dstStartY := itemY
 325
 326		if itemY < 0 {
 327			// Item starts above viewport
 328			srcStartY = -itemY
 329			dstStartY = 0
 330		}
 331
 332		srcEndY := srcStartY + (l.height - dstStartY)
 333		if srcEndY > itemHeight {
 334			srcEndY = itemHeight
 335		}
 336
 337		// Copy visible lines from item buffer to screen
 338		buf := cached.buffer.Buffer
 339		destY := area.Min.Y + dstStartY
 340
 341		for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
 342			if srcY >= buf.Height() {
 343				break
 344			}
 345
 346			line := buf.Line(srcY)
 347			destX := area.Min.X
 348
 349			for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
 350				cell := line.At(x)
 351				scr.SetCell(destX, destY, cell)
 352				destX++
 353			}
 354			destY++
 355		}
 356
 357		itemStartY += itemHeight
 358	}
 359}
 360
 361// pruneCache removes cached items outside the visible range.
 362func (l *LazyList) pruneCache(firstIdx, lastIdx int) {
 363	keepStart := max(0, firstIdx-l.overscan*2)
 364	keepEnd := min(len(l.items)-1, lastIdx+l.overscan*2)
 365
 366	for idx := range l.renderedCache {
 367		if idx < keepStart || idx > keepEnd {
 368			delete(l.renderedCache, idx)
 369		}
 370	}
 371}
 372
 373// clampOffset ensures scroll offset stays within valid bounds.
 374func (l *LazyList) clampOffset() {
 375	maxOffset := l.totalHeight - l.height
 376	if maxOffset < 0 {
 377		maxOffset = 0
 378	}
 379
 380	if l.offset > maxOffset {
 381		l.offset = maxOffset
 382	}
 383	if l.offset < 0 {
 384		l.offset = 0
 385	}
 386}
 387
 388// SetItems replaces all items in the list.
 389func (l *LazyList) SetItems(items []Item) {
 390	l.items = items
 391	l.itemHeights = make([]itemHeight, len(items))
 392	l.renderedCache = make(map[int]*renderedItemCache)
 393	l.dirtyItems = make(map[int]bool)
 394
 395	// Initialize with estimates
 396	for i := range l.items {
 397		l.itemHeights[i] = itemHeight{
 398			height:   l.defaultEstimate,
 399			measured: false,
 400		}
 401	}
 402	l.calculateTotalHeight()
 403	l.needsLayout = true
 404	l.dirtyViewport = true
 405}
 406
 407// AppendItem adds an item to the end of the list.
 408func (l *LazyList) AppendItem(item Item) {
 409	l.items = append(l.items, item)
 410	l.itemHeights = append(l.itemHeights, itemHeight{
 411		height:   l.defaultEstimate,
 412		measured: false,
 413	})
 414	l.totalHeight += l.defaultEstimate
 415	l.dirtyViewport = true
 416}
 417
 418// PrependItem adds an item to the beginning of the list.
 419func (l *LazyList) PrependItem(item Item) {
 420	l.items = append([]Item{item}, l.items...)
 421	l.itemHeights = append([]itemHeight{{
 422		height:   l.defaultEstimate,
 423		measured: false,
 424	}}, l.itemHeights...)
 425
 426	// Shift cache indices
 427	newCache := make(map[int]*renderedItemCache)
 428	for idx, cached := range l.renderedCache {
 429		newCache[idx+1] = cached
 430	}
 431	l.renderedCache = newCache
 432
 433	l.totalHeight += l.defaultEstimate
 434	l.offset += l.defaultEstimate // Maintain scroll position
 435	l.dirtyViewport = true
 436}
 437
 438// UpdateItem replaces an item at the given index.
 439func (l *LazyList) UpdateItem(idx int, item Item) {
 440	if idx < 0 || idx >= len(l.items) {
 441		return
 442	}
 443
 444	l.items[idx] = item
 445	delete(l.renderedCache, idx)
 446	l.dirtyItems[idx] = true
 447	// Keep height estimate - will remeasure on next render
 448	l.dirtyViewport = true
 449}
 450
 451// ScrollBy scrolls by the given number of lines.
 452func (l *LazyList) ScrollBy(delta int) {
 453	l.offset += delta
 454	l.clampOffset()
 455	l.dirtyViewport = true
 456}
 457
 458// ScrollToBottom scrolls to the end of the list.
 459func (l *LazyList) ScrollToBottom() {
 460	l.offset = l.totalHeight - l.height
 461	l.clampOffset()
 462	l.dirtyViewport = true
 463}
 464
 465// ScrollToTop scrolls to the beginning of the list.
 466func (l *LazyList) ScrollToTop() {
 467	l.offset = 0
 468	l.dirtyViewport = true
 469}
 470
 471// Len returns the number of items in the list.
 472func (l *LazyList) Len() int {
 473	return len(l.items)
 474}
 475
 476// Focus sets the list as focused.
 477func (l *LazyList) Focus() {
 478	l.focused = true
 479	l.focusSelectedItem()
 480	l.dirtyViewport = true
 481}
 482
 483// Blur removes focus from the list.
 484func (l *LazyList) Blur() {
 485	l.focused = false
 486	l.blurSelectedItem()
 487	l.dirtyViewport = true
 488}
 489
 490// focusSelectedItem focuses the currently selected item if it's focusable.
 491func (l *LazyList) focusSelectedItem() {
 492	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 493		return
 494	}
 495
 496	item := l.items[l.selectedIdx]
 497	if f, ok := item.(Focusable); ok {
 498		f.Focus()
 499		delete(l.renderedCache, l.selectedIdx)
 500		l.dirtyItems[l.selectedIdx] = true
 501	}
 502}
 503
 504// blurSelectedItem blurs the currently selected item if it's focusable.
 505func (l *LazyList) blurSelectedItem() {
 506	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 507		return
 508	}
 509
 510	item := l.items[l.selectedIdx]
 511	if f, ok := item.(Focusable); ok {
 512		f.Blur()
 513		delete(l.renderedCache, l.selectedIdx)
 514		l.dirtyItems[l.selectedIdx] = true
 515	}
 516}
 517
 518// IsFocused returns whether the list is focused.
 519func (l *LazyList) IsFocused() bool {
 520	return l.focused
 521}
 522
 523// Width returns the current viewport width.
 524func (l *LazyList) Width() int {
 525	return l.width
 526}
 527
 528// Height returns the current viewport height.
 529func (l *LazyList) Height() int {
 530	return l.height
 531}
 532
 533// SetSize sets the viewport size explicitly.
 534// This is useful when you want to pre-configure the list size before drawing.
 535func (l *LazyList) SetSize(width, height int) {
 536	widthChanged := l.width != width
 537	heightChanged := l.height != height
 538
 539	l.width = width
 540	l.height = height
 541
 542	// Width changes invalidate all cached renders
 543	if widthChanged && width > 0 {
 544		l.renderedCache = make(map[int]*renderedItemCache)
 545		// Mark all heights as needing remeasurement
 546		for i := range l.itemHeights {
 547			l.itemHeights[i].measured = false
 548			l.itemHeights[i].height = l.defaultEstimate
 549		}
 550		l.calculateTotalHeight()
 551		l.needsLayout = true
 552		l.dirtyViewport = true
 553	}
 554
 555	if heightChanged && height > 0 {
 556		l.clampOffset()
 557		l.dirtyViewport = true
 558	}
 559
 560	// After cache invalidation, scroll to selected item or bottom
 561	if widthChanged || heightChanged {
 562		if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
 563			// Scroll to selected item
 564			l.ScrollToSelected()
 565		} else if len(l.items) > 0 {
 566			// No selection - scroll to bottom
 567			l.ScrollToBottom()
 568		}
 569	}
 570}
 571
 572// Selection methods
 573
 574// Selected returns the currently selected item index (-1 if none).
 575func (l *LazyList) Selected() int {
 576	return l.selectedIdx
 577}
 578
 579// SetSelected sets the selected item by index.
 580func (l *LazyList) SetSelected(idx int) {
 581	if idx < -1 || idx >= len(l.items) {
 582		return
 583	}
 584
 585	if l.selectedIdx != idx {
 586		prevIdx := l.selectedIdx
 587		l.selectedIdx = idx
 588		l.dirtyViewport = true
 589
 590		// Update focus states if list is focused.
 591		if l.focused {
 592			// Blur previously selected item.
 593			if prevIdx >= 0 && prevIdx < len(l.items) {
 594				if f, ok := l.items[prevIdx].(Focusable); ok {
 595					f.Blur()
 596					delete(l.renderedCache, prevIdx)
 597					l.dirtyItems[prevIdx] = true
 598				}
 599			}
 600
 601			// Focus newly selected item.
 602			if idx >= 0 && idx < len(l.items) {
 603				if f, ok := l.items[idx].(Focusable); ok {
 604					f.Focus()
 605					delete(l.renderedCache, idx)
 606					l.dirtyItems[idx] = true
 607				}
 608			}
 609		}
 610	}
 611}
 612
 613// SelectPrev selects the previous item.
 614func (l *LazyList) SelectPrev() {
 615	if len(l.items) == 0 {
 616		return
 617	}
 618
 619	if l.selectedIdx <= 0 {
 620		l.selectedIdx = 0
 621	} else {
 622		l.selectedIdx--
 623	}
 624
 625	l.dirtyViewport = true
 626}
 627
 628// SelectNext selects the next item.
 629func (l *LazyList) SelectNext() {
 630	if len(l.items) == 0 {
 631		return
 632	}
 633
 634	if l.selectedIdx < 0 {
 635		l.selectedIdx = 0
 636	} else if l.selectedIdx < len(l.items)-1 {
 637		l.selectedIdx++
 638	}
 639
 640	l.dirtyViewport = true
 641}
 642
 643// SelectFirst selects the first item.
 644func (l *LazyList) SelectFirst() {
 645	if len(l.items) > 0 {
 646		l.selectedIdx = 0
 647		l.dirtyViewport = true
 648	}
 649}
 650
 651// SelectLast selects the last item.
 652func (l *LazyList) SelectLast() {
 653	if len(l.items) > 0 {
 654		l.selectedIdx = len(l.items) - 1
 655		l.dirtyViewport = true
 656	}
 657}
 658
 659// SelectFirstInView selects the first visible item in the viewport.
 660func (l *LazyList) SelectFirstInView() {
 661	if len(l.items) == 0 {
 662		return
 663	}
 664
 665	firstIdx, _ := l.findVisibleItems()
 666	l.selectedIdx = firstIdx
 667	l.dirtyViewport = true
 668}
 669
 670// SelectLastInView selects the last visible item in the viewport.
 671func (l *LazyList) SelectLastInView() {
 672	if len(l.items) == 0 {
 673		return
 674	}
 675
 676	_, lastIdx := l.findVisibleItems()
 677	l.selectedIdx = lastIdx
 678	l.dirtyViewport = true
 679}
 680
 681// SelectedItemInView returns whether the selected item is visible in the viewport.
 682func (l *LazyList) SelectedItemInView() bool {
 683	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 684		return false
 685	}
 686
 687	firstIdx, lastIdx := l.findVisibleItems()
 688	return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx
 689}
 690
 691// ScrollToSelected scrolls the viewport to ensure the selected item is visible.
 692func (l *LazyList) ScrollToSelected() {
 693	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 694		return
 695	}
 696
 697	// Get selected item position
 698	itemY := l.getItemPosition(l.selectedIdx)
 699	itemHeight := l.itemHeights[l.selectedIdx].height
 700
 701	// Check if item is above viewport
 702	if itemY < l.offset {
 703		l.offset = itemY
 704		l.dirtyViewport = true
 705		return
 706	}
 707
 708	// Check if item is below viewport
 709	itemBottom := itemY + itemHeight
 710	viewportBottom := l.offset + l.height
 711
 712	if itemBottom > viewportBottom {
 713		// Scroll so item bottom is at viewport bottom
 714		l.offset = itemBottom - l.height
 715		l.clampOffset()
 716		l.dirtyViewport = true
 717	}
 718}
 719
 720// Mouse interaction methods
 721
 722// HandleMouseDown handles mouse button down events.
 723// Returns true if the event was handled.
 724func (l *LazyList) HandleMouseDown(x, y int) bool {
 725	if x < 0 || y < 0 || x >= l.width || y >= l.height {
 726		return false
 727	}
 728
 729	// Find which item was clicked
 730	clickY := l.offset + y
 731	itemIdx := l.findItemAtY(clickY)
 732
 733	if itemIdx < 0 {
 734		return false
 735	}
 736
 737	// Calculate item-relative Y position.
 738	itemY := clickY - l.getItemPosition(itemIdx)
 739
 740	l.mouseDown = true
 741	l.mouseDownItem = itemIdx
 742	l.mouseDownX = x
 743	l.mouseDownY = itemY
 744	l.mouseDragItem = itemIdx
 745	l.mouseDragX = x
 746	l.mouseDragY = itemY
 747
 748	// Select the clicked item
 749	l.SetSelected(itemIdx)
 750
 751	return true
 752}
 753
 754// HandleMouseDrag handles mouse drag events.
 755func (l *LazyList) HandleMouseDrag(x, y int) {
 756	if !l.mouseDown {
 757		return
 758	}
 759
 760	// Find item under cursor
 761	if y >= 0 && y < l.height {
 762		dragY := l.offset + y
 763		itemIdx := l.findItemAtY(dragY)
 764		if itemIdx >= 0 {
 765			l.mouseDragItem = itemIdx
 766			// Calculate item-relative Y position.
 767			l.mouseDragY = dragY - l.getItemPosition(itemIdx)
 768			l.mouseDragX = x
 769		}
 770	}
 771
 772	// Update highlight if item supports it.
 773	l.updateHighlight()
 774}
 775
 776// HandleMouseUp handles mouse button up events.
 777func (l *LazyList) HandleMouseUp(x, y int) {
 778	if !l.mouseDown {
 779		return
 780	}
 781
 782	l.mouseDown = false
 783
 784	// Final highlight update.
 785	l.updateHighlight()
 786}
 787
 788// findItemAtY finds the item index at the given Y coordinate (in content space, not viewport).
 789func (l *LazyList) findItemAtY(y int) int {
 790	if y < 0 || len(l.items) == 0 {
 791		return -1
 792	}
 793
 794	pos := 0
 795	for i := 0; i < len(l.items); i++ {
 796		itemHeight := l.itemHeights[i].height
 797		if y >= pos && y < pos+itemHeight {
 798			return i
 799		}
 800		pos += itemHeight
 801	}
 802
 803	return -1
 804}
 805
 806// updateHighlight updates the highlight range for highlightable items.
 807// Supports highlighting within a single item and respects drag direction.
 808func (l *LazyList) updateHighlight() {
 809	if l.mouseDownItem < 0 {
 810		return
 811	}
 812
 813	// Get start and end item indices.
 814	downItemIdx := l.mouseDownItem
 815	dragItemIdx := l.mouseDragItem
 816
 817	// Determine selection direction.
 818	draggingDown := dragItemIdx > downItemIdx ||
 819		(dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
 820		(dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
 821
 822	// Determine actual start and end based on direction.
 823	var startItemIdx, endItemIdx int
 824	var startLine, startCol, endLine, endCol int
 825
 826	if draggingDown {
 827		// Normal forward selection.
 828		startItemIdx = downItemIdx
 829		endItemIdx = dragItemIdx
 830		startLine = l.mouseDownY
 831		startCol = l.mouseDownX
 832		endLine = l.mouseDragY
 833		endCol = l.mouseDragX
 834	} else {
 835		// Backward selection (dragging up).
 836		startItemIdx = dragItemIdx
 837		endItemIdx = downItemIdx
 838		startLine = l.mouseDragY
 839		startCol = l.mouseDragX
 840		endLine = l.mouseDownY
 841		endCol = l.mouseDownX
 842	}
 843
 844	// Clear all highlights first.
 845	for i, item := range l.items {
 846		if h, ok := item.(Highlightable); ok {
 847			h.SetHighlight(-1, -1, -1, -1)
 848			delete(l.renderedCache, i)
 849			l.dirtyItems[i] = true
 850		}
 851	}
 852
 853	// Highlight all items in range.
 854	for idx := startItemIdx; idx <= endItemIdx; idx++ {
 855		item, ok := l.items[idx].(Highlightable)
 856		if !ok {
 857			continue
 858		}
 859
 860		if idx == startItemIdx && idx == endItemIdx {
 861			// Single item selection.
 862			item.SetHighlight(startLine, startCol, endLine, endCol)
 863		} else if idx == startItemIdx {
 864			// First item - from start position to end of item.
 865			itemHeight := l.itemHeights[idx].height
 866			item.SetHighlight(startLine, startCol, itemHeight-1, 9999) // 9999 = end of line
 867		} else if idx == endItemIdx {
 868			// Last item - from start of item to end position.
 869			item.SetHighlight(0, 0, endLine, endCol)
 870		} else {
 871			// Middle item - fully highlighted.
 872			itemHeight := l.itemHeights[idx].height
 873			item.SetHighlight(0, 0, itemHeight-1, 9999)
 874		}
 875
 876		delete(l.renderedCache, idx)
 877		l.dirtyItems[idx] = true
 878	}
 879}
 880
 881// ClearHighlight clears any active text highlighting.
 882func (l *LazyList) ClearHighlight() {
 883	for i, item := range l.items {
 884		if h, ok := item.(Highlightable); ok {
 885			h.SetHighlight(-1, -1, -1, -1)
 886			delete(l.renderedCache, i)
 887			l.dirtyItems[i] = true
 888		}
 889	}
 890	l.mouseDownItem = -1
 891	l.mouseDragItem = -1
 892}
 893
 894// GetHighlightedText returns the plain text content of all highlighted regions
 895// across items, without any styling. Returns empty string if no highlights exist.
 896func (l *LazyList) GetHighlightedText() string {
 897	var result strings.Builder
 898
 899	// Iterate through items to find highlighted ones.
 900	for i, item := range l.items {
 901		h, ok := item.(Highlightable)
 902		if !ok {
 903			continue
 904		}
 905
 906		startLine, startCol, endLine, endCol := h.GetHighlight()
 907		if startLine < 0 {
 908			continue
 909		}
 910
 911		// Ensure item is rendered so we can access its buffer.
 912		if _, ok := l.renderedCache[i]; !ok {
 913			l.renderItem(i)
 914		}
 915
 916		cached := l.renderedCache[i]
 917		if cached == nil || cached.buffer == nil {
 918			continue
 919		}
 920
 921		buf := cached.buffer
 922		itemHeight := cached.height
 923
 924		// Extract text from highlighted region in item buffer.
 925		for y := startLine; y <= endLine && y < itemHeight; y++ {
 926			if y >= buf.Height() {
 927				break
 928			}
 929
 930			line := buf.Line(y)
 931
 932			// Determine column range for this line.
 933			colStart := 0
 934			if y == startLine {
 935				colStart = startCol
 936			}
 937
 938			colEnd := len(line)
 939			if y == endLine {
 940				colEnd = min(endCol, len(line))
 941			}
 942
 943			// Track last non-empty position to trim trailing spaces.
 944			lastContentX := -1
 945			for x := colStart; x < colEnd && x < len(line); x++ {
 946				cell := line.At(x)
 947				if cell == nil || cell.IsZero() {
 948					continue
 949				}
 950				if cell.Content != "" && cell.Content != " " {
 951					lastContentX = x
 952				}
 953			}
 954
 955			// Extract text from cells, up to last content.
 956			endX := colEnd
 957			if lastContentX >= 0 {
 958				endX = lastContentX + 1
 959			}
 960
 961			for x := colStart; x < endX && x < len(line); x++ {
 962				cell := line.At(x)
 963				if cell != nil && !cell.IsZero() {
 964					result.WriteString(cell.Content)
 965				}
 966			}
 967
 968			// Add newline if not the last line.
 969			if y < endLine {
 970				result.WriteString("\n")
 971			}
 972		}
 973
 974		// Add newline between items if this isn't the last highlighted item.
 975		if i < len(l.items)-1 {
 976			nextHasHighlight := false
 977			for j := i + 1; j < len(l.items); j++ {
 978				if h, ok := l.items[j].(Highlightable); ok {
 979					s, _, _, _ := h.GetHighlight()
 980					if s >= 0 {
 981						nextHasHighlight = true
 982						break
 983					}
 984				}
 985			}
 986			if nextHasHighlight {
 987				result.WriteString("\n")
 988			}
 989		}
 990	}
 991
 992	return result.String()
 993}
 994
 995func min(a, b int) int {
 996	if a < b {
 997		return a
 998	}
 999	return b
1000}
1001
1002func max(a, b int) int {
1003	if a > b {
1004		return a
1005	}
1006	return b
1007}