@@ -0,0 +1,1007 @@
1+package list
2+
3+import (
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.
12+type 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.
53+type 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.
59+type renderedItemCache struct {
60+ buffer *uv.ScreenBuffer
61+ height int // Actual measured height after rendering
62+}
63+
64+// NewLazyList creates a new lazy-rendering list.
65+func 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).
93+func (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.
101+func (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.
110+func (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.
152+func (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.
189+func (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.
296+func (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.
362+func (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.
374+func (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.
389+func (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.
408+func (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.
419+func (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.
439+func (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.
452+func (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.
459+func (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.
466+func (l *LazyList) ScrollToTop() {
467+ l.offset = 0
468+ l.dirtyViewport = true
469+}
470+
471+// Len returns the number of items in the list.
472+func (l *LazyList) Len() int {
473+ return len(l.items)
474+}
475+
476+// Focus sets the list as focused.
477+func (l *LazyList) Focus() {
478+ l.focused = true
479+ l.focusSelectedItem()
480+ l.dirtyViewport = true
481+}
482+
483+// Blur removes focus from the list.
484+func (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.
491+func (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.
505+func (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.
519+func (l *LazyList) IsFocused() bool {
520+ return l.focused
521+}
522+
523+// Width returns the current viewport width.
524+func (l *LazyList) Width() int {
525+ return l.width
526+}
527+
528+// Height returns the current viewport height.
529+func (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.
535+func (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).
575+func (l *LazyList) Selected() int {
576+ return l.selectedIdx
577+}
578+
579+// SetSelected sets the selected item by index.
580+func (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.
614+func (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.
629+func (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.
644+func (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.
652+func (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.
660+func (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.
671+func (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.
682+func (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.
692+func (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.
724+func (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.
755+func (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.
777+func (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).
789+func (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.
808+func (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.
882+func (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.
896+func (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+
995+func min(a, b int) int {
996+ if a < b {
997+ return a
998+ }
999+ return b
1000+}
1001+
1002+func max(a, b int) int {
1003+ if a > b {
1004+ return a
1005+ }
1006+ return b
1007+}