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}