@@ -0,0 +1,1007 @@
6+ uv "github.com/charmbracelet/ultraviolet"
7+ "github.com/charmbracelet/ultraviolet/screen"
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 {
21+ selectedIdx int // Currently selected item index (-1 if none)
23+ // Item positioning - tracks measured and estimated positions
24+ itemHeights []itemHeight
25+ totalHeight int // Sum of all item heights (measured or estimated)
28+ offset int // Scroll offset in lines from top
30+ // Rendered items cache - only visible items are rendered
31+ renderedCache map[int]*renderedItemCache
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
39+ dirtyItems map[int]bool
40+ dirtyViewport bool // True if we need to re-render viewport
52+// itemHeight tracks the height of an item - either measured or estimated.
53+type itemHeight struct {
55+ measured bool // true if height is actual measurement, false if estimate
58+// renderedItemCache stores a rendered item's buffer.
59+type renderedItemCache struct {
60+ buffer *uv.ScreenBuffer
61+ height int // Actual measured height after rendering
64+// NewLazyList creates a new lazy-rendering list.
65+func NewLazyList(items ...Item) *LazyList {
68+ itemHeights: make([]itemHeight, len(items)),
69+ renderedCache: make(map[int]*renderedItemCache),
70+ dirtyItems: make(map[int]bool),
74+ defaultEstimate: 10, // Conservative estimate: 5 lines per item
75+ overscan: 5, // Render 3 items above/below viewport
80+ // Initialize all items with estimated heights
81+ for i := range l.items {
82+ l.itemHeights[i] = itemHeight{
83+ height: l.defaultEstimate,
87+ l.calculateTotalHeight()
92+// calculateTotalHeight sums all item heights (measured or estimated).
93+func (l *LazyList) calculateTotalHeight() {
95+ for _, h := range l.itemHeights {
96+ l.totalHeight += h.height
100+// getItemPosition returns the Y position where an item starts.
101+func (l *LazyList) getItemPosition(idx int) int {
103+ for i := 0; i < idx && i < len(l.itemHeights); i++ {
104+ pos += l.itemHeights[i].height
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 {
115+ viewportStart := l.offset
116+ viewportEnd := l.offset + l.height
118+ // Find first visible item
121+ for i := 0; i < len(l.items); i++ {
122+ itemEnd := pos + l.itemHeights[i].height
123+ if itemEnd > viewportStart {
130+ // Apply overscan above
131+ firstIdx = max(0, firstIdx-l.overscan)
133+ // Find last visible item
135+ pos = l.getItemPosition(firstIdx)
136+ for i := firstIdx; i < len(l.items); i++ {
137+ if pos >= viewportEnd {
140+ pos += l.itemHeights[i].height
144+ // Apply overscan below
145+ lastIdx = min(len(l.items)-1, lastIdx+l.overscan)
147+ return firstIdx, lastIdx
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) {
157+ item := l.items[idx]
159+ // Measure actual height
160+ actualHeight := item.Height(l.width)
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)
167+ // Cache rendered item
168+ l.renderedCache[idx] = &renderedItemCache{
170+ height: actualHeight,
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,
181+ // Adjust total height
182+ l.totalHeight += actualHeight - oldHeight
188+// Draw implements uv.Drawable.
189+func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) {
190+ if area.Dx() <= 0 || area.Dy() <= 0 {
194+ widthChanged := l.width != area.Dx()
195+ heightChanged := l.height != area.Dy()
198+ l.height = area.Dy()
200+ // Width changes invalidate all cached renders
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
208+ l.calculateTotalHeight()
209+ l.needsLayout = true
210+ l.dirtyViewport = true
215+ l.dirtyViewport = true
218+ if len(l.items) == 0 {
219+ screen.ClearArea(scr, area)
223+ // Find visible items based on current estimates
224+ firstIdx, lastIdx := l.findVisibleItems()
226+ // Track the first visible item's position to maintain stability
227+ // Only stabilize if we're not at the top boundary
231+ for i := firstIdx; i <= lastIdx; i++ {
232+ itemPos := l.getItemPosition(i)
233+ if itemPos >= l.offset {
235+ stabilizeY = itemPos
241+ // Track if any heights changed during rendering
242+ heightsChanged := false
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
249+ if l.itemHeights[i].height != oldHeight {
250+ heightsChanged = true
252+ } else if l.dirtyItems[i] {
253+ // Re-render dirty items
254+ oldHeight := l.itemHeights[i].height
256+ delete(l.dirtyItems, i)
257+ if l.itemHeights[i].height != oldHeight {
258+ heightsChanged = true
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
268+ // Adjust offset to maintain visual stability
269+ l.offset += offsetDelta
272+ // Re-find visible items with adjusted positions
273+ firstIdx, lastIdx = l.findVisibleItems()
275+ // Render any newly visible items after position adjustments
276+ for i := firstIdx; i <= lastIdx; i++ {
277+ if _, cached := l.renderedCache[i]; !cached {
283+ // Clear old cache entries outside visible range
284+ if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 {
285+ l.pruneCache(firstIdx, lastIdx)
288+ // Composite visible items into viewport with stable positions
289+ l.drawViewport(scr, area, firstIdx, lastIdx)
291+ l.dirtyViewport = false
292+ l.needsLayout = false
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)
299+ itemStartY := l.getItemPosition(firstIdx)
301+ for i := firstIdx; i <= lastIdx; i++ {
302+ cached, ok := l.renderedCache[i]
307+ // Calculate where this item appears in viewport
308+ itemY := itemStartY - l.offset
309+ itemHeight := cached.height
311+ // Skip if entirely above viewport
312+ if itemY+itemHeight < 0 {
313+ itemStartY += itemHeight
317+ // Stop if entirely below viewport
318+ if itemY >= l.height {
322+ // Calculate visible portion of item
327+ // Item starts above viewport
332+ srcEndY := srcStartY + (l.height - dstStartY)
333+ if srcEndY > itemHeight {
334+ srcEndY = itemHeight
337+ // Copy visible lines from item buffer to screen
338+ buf := cached.buffer.Buffer
339+ destY := area.Min.Y + dstStartY
341+ for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
342+ if srcY >= buf.Height() {
346+ line := buf.Line(srcY)
349+ for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
351+ scr.SetCell(destX, destY, cell)
357+ itemStartY += itemHeight
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)
366+ for idx := range l.renderedCache {
367+ if idx < keepStart || idx > keepEnd {
368+ delete(l.renderedCache, idx)
373+// clampOffset ensures scroll offset stays within valid bounds.
374+func (l *LazyList) clampOffset() {
375+ maxOffset := l.totalHeight - l.height
380+ if l.offset > maxOffset {
381+ l.offset = maxOffset
388+// SetItems replaces all items in the list.
389+func (l *LazyList) SetItems(items []Item) {
391+ l.itemHeights = make([]itemHeight, len(items))
392+ l.renderedCache = make(map[int]*renderedItemCache)
393+ l.dirtyItems = make(map[int]bool)
395+ // Initialize with estimates
396+ for i := range l.items {
397+ l.itemHeights[i] = itemHeight{
398+ height: l.defaultEstimate,
402+ l.calculateTotalHeight()
403+ l.needsLayout = true
404+ l.dirtyViewport = true
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,
414+ l.totalHeight += l.defaultEstimate
415+ l.dirtyViewport = true
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,
424+ }}, l.itemHeights...)
426+ // Shift cache indices
427+ newCache := make(map[int]*renderedItemCache)
428+ for idx, cached := range l.renderedCache {
429+ newCache[idx+1] = cached
431+ l.renderedCache = newCache
433+ l.totalHeight += l.defaultEstimate
434+ l.offset += l.defaultEstimate // Maintain scroll position
435+ l.dirtyViewport = true
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) {
445+ delete(l.renderedCache, idx)
446+ l.dirtyItems[idx] = true
447+ // Keep height estimate - will remeasure on next render
448+ l.dirtyViewport = true
451+// ScrollBy scrolls by the given number of lines.
452+func (l *LazyList) ScrollBy(delta int) {
455+ l.dirtyViewport = true
458+// ScrollToBottom scrolls to the end of the list.
459+func (l *LazyList) ScrollToBottom() {
460+ l.offset = l.totalHeight - l.height
462+ l.dirtyViewport = true
465+// ScrollToTop scrolls to the beginning of the list.
466+func (l *LazyList) ScrollToTop() {
468+ l.dirtyViewport = true
471+// Len returns the number of items in the list.
472+func (l *LazyList) Len() int {
476+// Focus sets the list as focused.
477+func (l *LazyList) Focus() {
479+ l.focusSelectedItem()
480+ l.dirtyViewport = true
483+// Blur removes focus from the list.
484+func (l *LazyList) Blur() {
486+ l.blurSelectedItem()
487+ l.dirtyViewport = true
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) {
496+ item := l.items[l.selectedIdx]
497+ if f, ok := item.(Focusable); ok {
499+ delete(l.renderedCache, l.selectedIdx)
500+ l.dirtyItems[l.selectedIdx] = true
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) {
510+ item := l.items[l.selectedIdx]
511+ if f, ok := item.(Focusable); ok {
513+ delete(l.renderedCache, l.selectedIdx)
514+ l.dirtyItems[l.selectedIdx] = true
518+// IsFocused returns whether the list is focused.
519+func (l *LazyList) IsFocused() bool {
523+// Width returns the current viewport width.
524+func (l *LazyList) Width() int {
528+// Height returns the current viewport height.
529+func (l *LazyList) Height() int {
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
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
550+ l.calculateTotalHeight()
551+ l.needsLayout = true
552+ l.dirtyViewport = true
555+ if heightChanged && height > 0 {
557+ l.dirtyViewport = true
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
574+// Selected returns the currently selected item index (-1 if none).
575+func (l *LazyList) Selected() int {
576+ return l.selectedIdx
579+// SetSelected sets the selected item by index.
580+func (l *LazyList) SetSelected(idx int) {
581+ if idx < -1 || idx >= len(l.items) {
585+ if l.selectedIdx != idx {
586+ prevIdx := l.selectedIdx
588+ l.dirtyViewport = true
590+ // Update focus states if list is focused.
592+ // Blur previously selected item.
593+ if prevIdx >= 0 && prevIdx < len(l.items) {
594+ if f, ok := l.items[prevIdx].(Focusable); ok {
596+ delete(l.renderedCache, prevIdx)
597+ l.dirtyItems[prevIdx] = true
601+ // Focus newly selected item.
602+ if idx >= 0 && idx < len(l.items) {
603+ if f, ok := l.items[idx].(Focusable); ok {
605+ delete(l.renderedCache, idx)
606+ l.dirtyItems[idx] = true
613+// SelectPrev selects the previous item.
614+func (l *LazyList) SelectPrev() {
615+ if len(l.items) == 0 {
619+ if l.selectedIdx <= 0 {
625+ l.dirtyViewport = true
628+// SelectNext selects the next item.
629+func (l *LazyList) SelectNext() {
630+ if len(l.items) == 0 {
634+ if l.selectedIdx < 0 {
636+ } else if l.selectedIdx < len(l.items)-1 {
640+ l.dirtyViewport = true
643+// SelectFirst selects the first item.
644+func (l *LazyList) SelectFirst() {
645+ if len(l.items) > 0 {
647+ l.dirtyViewport = true
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
659+// SelectFirstInView selects the first visible item in the viewport.
660+func (l *LazyList) SelectFirstInView() {
661+ if len(l.items) == 0 {
665+ firstIdx, _ := l.findVisibleItems()
666+ l.selectedIdx = firstIdx
667+ l.dirtyViewport = true
670+// SelectLastInView selects the last visible item in the viewport.
671+func (l *LazyList) SelectLastInView() {
672+ if len(l.items) == 0 {
676+ _, lastIdx := l.findVisibleItems()
677+ l.selectedIdx = lastIdx
678+ l.dirtyViewport = true
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) {
687+ firstIdx, lastIdx := l.findVisibleItems()
688+ return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx
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) {
697+ // Get selected item position
698+ itemY := l.getItemPosition(l.selectedIdx)
699+ itemHeight := l.itemHeights[l.selectedIdx].height
701+ // Check if item is above viewport
702+ if itemY < l.offset {
704+ l.dirtyViewport = true
708+ // Check if item is below viewport
709+ itemBottom := itemY + itemHeight
710+ viewportBottom := l.offset + l.height
712+ if itemBottom > viewportBottom {
713+ // Scroll so item bottom is at viewport bottom
714+ l.offset = itemBottom - l.height
716+ l.dirtyViewport = true
720+// Mouse interaction methods
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 {
729+ // Find which item was clicked
730+ clickY := l.offset + y
731+ itemIdx := l.findItemAtY(clickY)
737+ // Calculate item-relative Y position.
738+ itemY := clickY - l.getItemPosition(itemIdx)
741+ l.mouseDownItem = itemIdx
743+ l.mouseDownY = itemY
744+ l.mouseDragItem = itemIdx
746+ l.mouseDragY = itemY
748+ // Select the clicked item
749+ l.SetSelected(itemIdx)
754+// HandleMouseDrag handles mouse drag events.
755+func (l *LazyList) HandleMouseDrag(x, y int) {
760+ // Find item under cursor
761+ if y >= 0 && y < l.height {
762+ dragY := l.offset + y
763+ itemIdx := l.findItemAtY(dragY)
765+ l.mouseDragItem = itemIdx
766+ // Calculate item-relative Y position.
767+ l.mouseDragY = dragY - l.getItemPosition(itemIdx)
772+ // Update highlight if item supports it.
776+// HandleMouseUp handles mouse button up events.
777+func (l *LazyList) HandleMouseUp(x, y int) {
784+ // Final highlight update.
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 {
795+ for i := 0; i < len(l.items); i++ {
796+ itemHeight := l.itemHeights[i].height
797+ if y >= pos && y < pos+itemHeight {
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 {
813+ // Get start and end item indices.
814+ downItemIdx := l.mouseDownItem
815+ dragItemIdx := l.mouseDragItem
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)
822+ // Determine actual start and end based on direction.
823+ var startItemIdx, endItemIdx int
824+ var startLine, startCol, endLine, endCol int
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
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
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
853+ // Highlight all items in range.
854+ for idx := startItemIdx; idx <= endItemIdx; idx++ {
855+ item, ok := l.items[idx].(Highlightable)
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)
871+ // Middle item - fully highlighted.
872+ itemHeight := l.itemHeights[idx].height
873+ item.SetHighlight(0, 0, itemHeight-1, 9999)
876+ delete(l.renderedCache, idx)
877+ l.dirtyItems[idx] = true
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
890+ l.mouseDownItem = -1
891+ l.mouseDragItem = -1
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
899+ // Iterate through items to find highlighted ones.
900+ for i, item := range l.items {
901+ h, ok := item.(Highlightable)
906+ startLine, startCol, endLine, endCol := h.GetHighlight()
911+ // Ensure item is rendered so we can access its buffer.
912+ if _, ok := l.renderedCache[i]; !ok {
916+ cached := l.renderedCache[i]
917+ if cached == nil || cached.buffer == nil {
921+ buf := cached.buffer
922+ itemHeight := cached.height
924+ // Extract text from highlighted region in item buffer.
925+ for y := startLine; y <= endLine && y < itemHeight; y++ {
926+ if y >= buf.Height() {
932+ // Determine column range for this line.
940+ colEnd = min(endCol, len(line))
943+ // Track last non-empty position to trim trailing spaces.
945+ for x := colStart; x < colEnd && x < len(line); x++ {
947+ if cell == nil || cell.IsZero() {
950+ if cell.Content != "" && cell.Content != " " {
955+ // Extract text from cells, up to last content.
957+ if lastContentX >= 0 {
958+ endX = lastContentX + 1
961+ for x := colStart; x < endX && x < len(line); x++ {
963+ if cell != nil && !cell.IsZero() {
964+ result.WriteString(cell.Content)
968+ // Add newline if not the last line.
970+ result.WriteString("\n")
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()
981+ nextHasHighlight = true
986+ if nextHasHighlight {
987+ result.WriteString("\n")
992+ return result.String()
995+func min(a, b int) int {
1002+func max(a, b int) int {