fix: change index to int

Raphael Amorim created

Change summary

internal/tui/exp/list/grouped.go   |   3 
internal/tui/exp/list/list.go      | 326 +++++++++++++++++++------------
internal/tui/exp/list/list_test.go |  82 ++++---
3 files changed, 240 insertions(+), 171 deletions(-)

Detailed changes

internal/tui/exp/list/grouped.go 🔗

@@ -42,7 +42,8 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
 		},
 		items:         csync.NewSlice[Item](),
 		indexMap:      csync.NewMap[string, int](),
-		renderedItems: csync.NewMap[string, renderedItem](),
+		viewCache:     csync.NewMap[string, string](),
+		itemPositions: nil,
 	}
 	for _, opt := range opts {
 		opt(list.confOptions)

internal/tui/exp/list/list.go 🔗

@@ -71,9 +71,7 @@ const (
 	ViewportDefaultScrollSize = 2
 )
 
-type renderedItem struct {
-	id     string
-	view   string
+type itemPosition struct {
 	height int
 	start  int
 	end    int
@@ -100,15 +98,14 @@ type list[T Item] struct {
 	indexMap *csync.Map[string, int]
 	items    *csync.Slice[T]
 
-	renderedItems *csync.Map[string, renderedItem]
+	// Virtual scrolling fields - using slices for O(1) index access
+	itemPositions []itemPosition // Position info for each item by index
+	virtualHeight int             // Total height of all items
+	viewCache     *csync.Map[string, string] // Optional cache for rendered views
 
 	renderMu sync.Mutex
 	rendered string
 
-	// Virtual scrolling fields
-	virtualHeight int                     // Total height of all items
-	itemHeights   *csync.Map[string, int] // Cache of item heights
-
 	movingByItem       bool
 	selectionStartCol  int
 	selectionStartLine int
@@ -195,8 +192,8 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
 		},
 		items:              csync.NewSliceFrom(items),
 		indexMap:           csync.NewMap[string, int](),
-		renderedItems:      csync.NewMap[string, renderedItem](),
-		itemHeights:        csync.NewMap[string, int](),
+		itemPositions:      make([]itemPosition, len(items)),
+		viewCache:          csync.NewMap[string, string](),
 		selectionStartCol:  -1,
 		selectionStartLine: -1,
 		selectionEndLine:   -1,
@@ -231,16 +228,19 @@ func (l *list[T]) Init() tea.Cmd {
 		}
 	}
 	
+	// Calculate positions for all items
+	l.calculateItemPositions()
+	
 	// For backward lists, we need to position at the bottom after initial render
 	if l.direction == DirectionBackward && l.offset == 0 && l.items.Len() > 0 {
-		// Calculate positions first
-		l.calculateItemPositions()
 		// Set offset to show the bottom of the list
 		if l.virtualHeight > l.height {
 			l.offset = 0 // In backward mode, offset 0 means bottom
 		}
-		// Select the last item
-		l.selectLastItem()
+		// Select the last item if no item is selected
+		if l.selectedItem == "" {
+			l.selectLastItem()
+		}
 	}
 	
 	// Scroll to the selected item for initial positioning
@@ -568,12 +568,18 @@ func (l *list[T]) setDefaultSelected() {
 }
 
 func (l *list[T]) scrollToSelection() {
-	rItem, ok := l.renderedItems.Get(l.selectedItem)
-	if !ok {
+	if l.selectedItem == "" {
+		return
+	}
+	
+	inx, ok := l.indexMap.Get(l.selectedItem)
+	if !ok || inx < 0 || inx >= len(l.itemPositions) {
 		l.selectedItem = ""
 		l.setDefaultSelected()
 		return
 	}
+	
+	rItem := l.itemPositions[inx]
 
 	start, end := l.viewPosition()
 	
@@ -633,10 +639,12 @@ func (l *list[T]) scrollToSelection() {
 }
 
 func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
-	rItem, ok := l.renderedItems.Get(l.selectedItem)
-	if !ok {
+	inx, ok := l.indexMap.Get(l.selectedItem)
+	if !ok || inx < 0 || inx >= len(l.itemPositions) {
 		return nil
 	}
+	
+	rItem := l.itemPositions[inx]
 	start, end := l.viewPosition()
 	// item bigger than the viewport do nothing
 	if rItem.start <= start && rItem.end >= end {
@@ -652,10 +660,6 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 	if itemMiddle < start {
 		// select the first item in the viewport
 		// the item is most likely an item coming after this item
-		inx, ok := l.indexMap.Get(rItem.id)
-		if !ok {
-			return nil
-		}
 		for {
 			inx = l.firstSelectableItemBelow(inx)
 			if inx == ItemNotFound {
@@ -665,29 +669,25 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 			if !ok {
 				continue
 			}
-			renderedItem, ok := l.renderedItems.Get(item.ID())
-			if !ok {
+			if inx >= len(l.itemPositions) {
 				continue
 			}
+			renderedItem := l.itemPositions[inx]
 
 			// If the item is bigger than the viewport, select it
 			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItem = item.ID()
 				return l.render()
 			}
 			// item is in the view
 			if renderedItem.start >= start && renderedItem.start <= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItem = item.ID()
 				return l.render()
 			}
 		}
 	} else if itemMiddle > end {
 		// select the first item in the viewport
 		// the item is most likely an item coming after this item
-		inx, ok := l.indexMap.Get(rItem.id)
-		if !ok {
-			return nil
-		}
 		for {
 			inx = l.firstSelectableItemAbove(inx)
 			if inx == ItemNotFound {
@@ -697,19 +697,19 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 			if !ok {
 				continue
 			}
-			renderedItem, ok := l.renderedItems.Get(item.ID())
-			if !ok {
+			if inx >= len(l.itemPositions) {
 				continue
 			}
+			renderedItem := l.itemPositions[inx]
 
 			// If the item is bigger than the viewport, select it
 			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItem = item.ID()
 				return l.render()
 			}
 			// item is in the view
 			if renderedItem.end >= start && renderedItem.end <= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItem = item.ID()
 				return l.render()
 			}
 		}
@@ -779,10 +779,10 @@ func (l *list[T]) focusSelectedItem() tea.Cmd {
 		if f, ok := any(item).(layout.Focusable); ok {
 			if item.ID() == l.selectedItem && !f.IsFocused() {
 				cmds = append(cmds, f.Focus())
-				l.renderedItems.Del(item.ID())
+				l.viewCache.Del(item.ID())
 			} else if item.ID() != l.selectedItem && f.IsFocused() {
 				cmds = append(cmds, f.Blur())
-				l.renderedItems.Del(item.ID())
+				l.viewCache.Del(item.ID())
 			}
 		}
 	}
@@ -798,7 +798,7 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
 		if f, ok := any(item).(layout.Focusable); ok {
 			if item.ID() == l.selectedItem && f.IsFocused() {
 				cmds = append(cmds, f.Blur())
-				l.renderedItems.Del(item.ID())
+				l.viewCache.Del(item.ID())
 			}
 		}
 	}
@@ -808,10 +808,16 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
 
 
 // calculateItemPositions calculates and caches the position and height of all items.
+// This is O(n) but only called when the list structure changes significantly.
 func (l *list[T]) calculateItemPositions() {
-	currentHeight := 0
 	itemsLen := l.items.Len()
-
+	
+	// Resize positions slice if needed
+	if len(l.itemPositions) != itemsLen {
+		l.itemPositions = make([]itemPosition, itemsLen)
+	}
+	
+	currentHeight := 0
 	// Always calculate positions in forward order (logical positions)
 	for i := 0; i < itemsLen; i++ {
 		item, ok := l.items.Get(i)
@@ -819,34 +825,24 @@ func (l *list[T]) calculateItemPositions() {
 			continue
 		}
 
-		// Get or calculate item height
-		var height int
-		if cachedHeight, ok := l.itemHeights.Get(item.ID()); ok {
-			height = cachedHeight
+		// Get cached view or render new one
+		var view string
+		if cached, ok := l.viewCache.Get(item.ID()); ok {
+			view = cached
 		} else {
-			// Calculate and cache the height
-			view := item.View()
-			height = lipgloss.Height(view)
-			l.itemHeights.Set(item.ID(), height)
+			view = item.View()
+			l.viewCache.Set(item.ID(), view)
 		}
-
-		// Update or create rendered item with position info
-		var rItem renderedItem
-		if cached, ok := l.renderedItems.Get(item.ID()); ok {
-			rItem = cached
-			rItem.height = height
-		} else {
-			rItem = renderedItem{
-				id:     item.ID(),
-				height: height,
-			}
+		
+		height := lipgloss.Height(view)
+		
+		l.itemPositions[i] = itemPosition{
+			height: height,
+			start:  currentHeight,
+			end:    currentHeight + height - 1,
 		}
 		
-		rItem.start = currentHeight
-		rItem.end = currentHeight + rItem.height - 1
-		l.renderedItems.Set(item.ID(), rItem)
-
-		currentHeight = rItem.end + 1
+		currentHeight += height
 		if i < itemsLen-1 {
 			currentHeight += l.gap
 		}
@@ -855,6 +851,58 @@ func (l *list[T]) calculateItemPositions() {
 	l.virtualHeight = currentHeight
 }
 
+// updateItemPosition updates a single item's position and adjusts subsequent items.
+// This is O(n) in worst case but only for items after the changed one.
+func (l *list[T]) updateItemPosition(index int) {
+	itemsLen := l.items.Len()
+	if index < 0 || index >= itemsLen {
+		return
+	}
+	
+	item, ok := l.items.Get(index)
+	if !ok {
+		return
+	}
+	
+	// Get new height
+	view := item.View()
+	l.viewCache.Set(item.ID(), view)
+	newHeight := lipgloss.Height(view)
+	
+	// If height hasn't changed, no need to update
+	if index < len(l.itemPositions) && l.itemPositions[index].height == newHeight {
+		return
+	}
+	
+	// Calculate starting position (from previous item or 0)
+	var startPos int
+	if index > 0 {
+		startPos = l.itemPositions[index-1].end + 1 + l.gap
+	}
+	
+	// Update this item
+	oldHeight := 0
+	if index < len(l.itemPositions) {
+		oldHeight = l.itemPositions[index].height
+	}
+	heightDiff := newHeight - oldHeight
+	
+	l.itemPositions[index] = itemPosition{
+		height: newHeight,
+		start:  startPos,
+		end:    startPos + newHeight - 1,
+	}
+	
+	// Update all subsequent items' positions (shift by heightDiff)
+	for i := index + 1; i < len(l.itemPositions); i++ {
+		l.itemPositions[i].start += heightDiff
+		l.itemPositions[i].end += heightDiff
+	}
+	
+	// Update total height
+	l.virtualHeight += heightDiff
+}
+
 // renderVirtualScrolling renders only the visible portion of the list.
 func (l *list[T]) renderVirtualScrolling() string {
 	if l.items.Len() == 0 {
@@ -874,8 +922,8 @@ func (l *list[T]) renderVirtualScrolling() string {
 		return strings.Join(lines, "\n")
 	}
 	
-	// Debug: Check if we have any rendered items
-	if l.renderedItems.Len() == 0 {
+	// Check if we have any positions calculated
+	if len(l.itemPositions) == 0 {
 		// No items have been calculated yet, return empty
 		var lines []string
 		for i := 0; i < l.height; i++ {
@@ -886,9 +934,9 @@ func (l *list[T]) renderVirtualScrolling() string {
 	
 	// Find which items are visible
 	var visibleItems []struct {
-		item   T
-		rItem  renderedItem
-		index  int
+		item  T
+		pos   itemPosition
+		index int
 	}
 	
 	itemsLen := l.items.Len()
@@ -898,23 +946,24 @@ func (l *list[T]) renderVirtualScrolling() string {
 			continue
 		}
 		
-		rItem, ok := l.renderedItems.Get(item.ID())
-		if !ok {
+		if i >= len(l.itemPositions) {
 			// Item not yet calculated, skip it
 			continue
 		}
 		
+		pos := l.itemPositions[i]
+		
 		// Check if item is visible (overlaps with viewport)
-		if rItem.end >= viewStart && rItem.start <= viewEnd {
+		if pos.end >= viewStart && pos.start <= viewEnd {
 			visibleItems = append(visibleItems, struct {
 				item  T
-				rItem renderedItem
+				pos   itemPosition
 				index int
-			}{item, rItem, i})
+			}{item, pos, i})
 		}
 		
 		// Early exit if we've passed the viewport
-		if rItem.start > viewEnd {
+		if pos.start > viewEnd {
 			break
 		}
 	}
@@ -935,25 +984,24 @@ func (l *list[T]) renderVirtualScrolling() string {
 	
 	// Handle first visible item
 	firstVisible := visibleItems[0]
-	if firstVisible.rItem.start < viewStart {
+	if firstVisible.pos.start < viewStart {
 		// We're starting mid-item, render partial
-		if cached, ok := l.renderedItems.Get(firstVisible.item.ID()); ok && cached.view != "" {
-			lines := strings.Split(cached.view, "\n")
-			skipLines := viewStart - firstVisible.rItem.start
+		if cached, ok := l.viewCache.Get(firstVisible.item.ID()); ok && cached != "" {
+			lines := strings.Split(cached, "\n")
+			skipLines := viewStart - firstVisible.pos.start
 			if skipLines >= 0 && skipLines < len(lines) {
-				for i := skipLines; i < len(lines); i++ {
-					if currentLine > viewEnd {
-						break
+				for i := skipLines; i < len(lines) && currentLine <= viewEnd; i++ {
+					if b.Len() > 0 {
+						b.WriteByte('\n')
 					}
 					b.WriteString(lines[i])
-					b.WriteByte('\n')
 					currentLine++
 				}
 			}
 		}
-	} else if firstVisible.rItem.start > viewStart {
+	} else if firstVisible.pos.start > viewStart {
 		// Add empty lines before first item
-		for currentLine < firstVisible.rItem.start && currentLine <= viewEnd {
+		for currentLine < firstVisible.pos.start && currentLine <= viewEnd {
 			if b.Len() > 0 {
 				b.WriteByte('\n')
 			}
@@ -968,28 +1016,33 @@ func (l *list[T]) renderVirtualScrolling() string {
 		}
 		
 		// Skip first item if we already rendered it partially
-		if i == 0 && firstVisible.rItem.start < viewStart {
+		if i == 0 && firstVisible.pos.start < viewStart {
+			// Update currentLine to where we left off after partial rendering
+			currentLine = viewStart + (firstVisible.pos.end - firstVisible.pos.start + 1) - (viewStart - firstVisible.pos.start)
 			continue
 		}
 		
-		// Add gap before item (except for first)
-		if i > 0 && currentLine <= viewEnd {
-			for j := 0; j < l.gap && currentLine <= viewEnd; j++ {
-				b.WriteByte('\n')
-				currentLine++
+		// Add gap before item (except for first visible item in viewport)
+		if i > 0 || (i == 0 && firstVisible.pos.start >= viewStart) {
+			// Only add gap if this isn't the very first item in the viewport
+			if currentLine > viewStart && currentLine <= viewEnd {
+				for j := 0; j < l.gap && currentLine <= viewEnd; j++ {
+					if b.Len() > 0 {
+						b.WriteByte('\n')
+					}
+					currentLine++
+				}
 			}
 		}
 		
 		// Render item or use cache
 		var view string
-		if cached, ok := l.renderedItems.Get(vis.item.ID()); ok && cached.view != "" {
-			view = cached.view
+		if cached, ok := l.viewCache.Get(vis.item.ID()); ok && cached != "" {
+			view = cached
 		} else {
 			view = vis.item.View()
 			// Update cache
-			rItem := vis.rItem
-			rItem.view = view
-			l.renderedItems.Set(vis.item.ID(), rItem)
+			l.viewCache.Set(vis.item.ID(), view)
 		}
 		
 		// Handle partial rendering if item extends beyond viewport
@@ -1049,8 +1102,10 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
 				cmds = append(cmds, cmd)
 			}
 		} else {
-			newItem, ok := l.renderedItems.Get(item.ID())
-			if ok {
+			// Get the new item's position to adjust offset
+			newInx := l.items.Len() - 1
+			if newInx < len(l.itemPositions) {
+				newItem := l.itemPositions[newInx]
 				newLines := newItem.height
 				if l.items.Len() > 1 {
 					newLines += l.gap
@@ -1077,7 +1132,9 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
 		return nil
 	}
 	l.items.Delete(inx)
-	l.renderedItems.Del(id)
+	l.viewCache.Del(id)
+	// Rebuild index map
+	l.indexMap = csync.NewMap[string, int]()
 	for inx, item := range slices.Collect(l.items.Seq()) {
 		l.indexMap.Set(item.ID(), inx)
 	}
@@ -1258,8 +1315,10 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
 				cmds = append(cmds, cmd)
 			}
 		} else {
-			newItem, ok := l.renderedItems.Get(item.ID())
-			if ok {
+			// Get the new item's position to adjust offset
+			newInx := l.items.Len() - 1
+			if newInx < len(l.itemPositions) {
+				newItem := l.itemPositions[newInx]
 				newLines := newItem.height
 				if l.items.Len() > 1 {
 					newLines += l.gap
@@ -1372,8 +1431,8 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd {
 	l.offset = 0
 	l.selectedItem = selectedItem
 	l.indexMap = csync.NewMap[string, int]()
-	l.renderedItems = csync.NewMap[string, renderedItem]()
-	l.itemHeights = csync.NewMap[string, int]()
+	l.viewCache = csync.NewMap[string, string]()
+	l.itemPositions = nil // Will be recalculated
 	l.virtualHeight = 0
 	for inx, item := range slices.Collect(l.items.Seq()) {
 		l.indexMap.Set(item.ID(), inx)
@@ -1401,20 +1460,28 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
 	var cmds []tea.Cmd
 	if inx, ok := l.indexMap.Get(id); ok {
-		l.items.Set(inx, item)
-		oldItem, hasOldItem := l.renderedItems.Get(id)
+		// Store old height if we have it
+		var oldHeight int
+		hasOldItem := false
+		if inx < len(l.itemPositions) {
+			oldHeight = l.itemPositions[inx].height
+			hasOldItem = true
+		}
+		
 		oldPosition := l.offset
 		if l.direction == DirectionBackward {
 			if l.virtualHeight > 0 {
-			oldPosition = (l.virtualHeight - 1) - l.offset
-		} else {
-			oldPosition = 0
-		}
+				oldPosition = (l.virtualHeight - 1) - l.offset
+			} else {
+				oldPosition = 0
+			}
 		}
 
-		// Clear caches for this item
-		l.renderedItems.Del(id)
-		l.itemHeights.Del(id)
+		// Update the item
+		l.items.Set(inx, item)
+		
+		// Clear cache for this item
+		l.viewCache.Del(id)
 		
 		cmd := l.render()
 
@@ -1422,28 +1489,23 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
-		if hasOldItem && l.direction == DirectionBackward {
-			// if we are the last item and there is no offset
-			// make sure to go to the bottom
-			if oldPosition < oldItem.end {
-				newItem, ok := l.renderedItems.Get(item.ID())
-				if ok {
-					newLines := newItem.height - oldItem.height
-					if l.virtualHeight > 0 {
-					l.offset = util.Clamp(l.offset+newLines, 0, l.virtualHeight-1)
-				} else {
-					l.offset = 0
-				}
+		
+		// Adjust offset if needed based on height change
+		if hasOldItem && inx < len(l.itemPositions) {
+			newHeight := l.itemPositions[inx].height
+			diff := newHeight - oldHeight
+			
+			if l.direction == DirectionBackward {
+				// if we are the last item and there is no offset
+				// make sure to go to the bottom
+				if oldPosition < l.itemPositions[inx].end {
+					if diff != 0 && l.virtualHeight > 0 {
+						l.offset = util.Clamp(l.offset+diff, 0, l.virtualHeight-1)
+					}
 				}
-			}
-		} else if hasOldItem && l.offset > oldItem.start {
-			newItem, ok := l.renderedItems.Get(item.ID())
-			if ok {
-				newLines := newItem.height - oldItem.height
-				if l.virtualHeight > 0 {
-					l.offset = util.Clamp(l.offset+newLines, 0, l.virtualHeight-1)
-				} else {
-					l.offset = 0
+			} else if hasOldItem && l.offset > l.itemPositions[inx].start {
+				if diff != 0 && l.virtualHeight > 0 {
+					l.offset = util.Clamp(l.offset+diff, 0, l.virtualHeight-1)
 				}
 			}
 		}

internal/tui/exp/list/list_test.go 🔗

@@ -26,20 +26,19 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[0].ID(), l.selectedItem)
 		assert.Equal(t, 0, l.offset)
 		require.Equal(t, 5, l.indexMap.Len())
 		require.Equal(t, 5, l.items.Len())
-		require.Equal(t, 5, l.renderedItems.Len())
+		require.Equal(t, 5, len(l.itemPositions))
 		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 0, start)
 		assert.Equal(t, 4, end)
 		for i := range 5 {
-			item, ok := l.renderedItems.Get(items[i].ID())
-			require.True(t, ok)
+			item := l.itemPositions[i]
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
 		}
@@ -56,20 +55,19 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[4].ID(), l.selectedItem)
 		assert.Equal(t, 0, l.offset)
 		require.Equal(t, 5, l.indexMap.Len())
 		require.Equal(t, 5, l.items.Len())
-		require.Equal(t, 5, l.renderedItems.Len())
+		require.Equal(t, 5, len(l.itemPositions))
 		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 0, start)
 		assert.Equal(t, 4, end)
 		for i := range 5 {
-			item, ok := l.renderedItems.Get(items[i].ID())
-			require.True(t, ok)
+			item := l.itemPositions[i]
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
 		}
@@ -87,20 +85,20 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[0].ID(), l.selectedItem)
 		assert.Equal(t, 0, l.offset)
 		require.Equal(t, 30, l.indexMap.Len())
 		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
-		assert.Equal(t, 30, lipgloss.Height(l.rendered))
+		require.Equal(t, 30, len(l.itemPositions))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 0, start)
 		assert.Equal(t, 9, end)
 		for i := range 30 {
-			item, ok := l.renderedItems.Get(items[i].ID())
-			require.True(t, ok)
+			item := l.itemPositions[i]
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
 		}
@@ -117,20 +115,20 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[29].ID(), l.selectedItem)
 		assert.Equal(t, 0, l.offset)
 		require.Equal(t, 30, l.indexMap.Len())
 		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
-		assert.Equal(t, 30, lipgloss.Height(l.rendered))
+		require.Equal(t, 30, len(l.itemPositions))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 		start, end := l.viewPosition()
 		assert.Equal(t, 20, start)
 		assert.Equal(t, 29, end)
 		for i := range 30 {
-			item, ok := l.renderedItems.Get(items[i].ID())
-			require.True(t, ok)
+			item := l.itemPositions[i]
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
 		}
@@ -150,12 +148,12 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[0].ID(), l.selectedItem)
 		assert.Equal(t, 0, l.offset)
 		require.Equal(t, 30, l.indexMap.Len())
 		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
+		require.Equal(t, 30, len(l.itemPositions))
 		expectedLines := 0
 		for i := range 30 {
 			expectedLines += (i + 1) * 1
@@ -170,8 +168,7 @@ func TestList(t *testing.T) {
 		assert.Equal(t, 9, end)
 		currentPosition := 0
 		for i := range 30 {
-			rItem, ok := l.renderedItems.Get(items[i].ID())
-			require.True(t, ok)
+			rItem := l.itemPositions[i]
 			assert.Equal(t, currentPosition, rItem.start)
 			assert.Equal(t, currentPosition+i, rItem.end)
 			currentPosition += i + 1
@@ -191,12 +188,12 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[29].ID(), l.selectedItem)
 		assert.Equal(t, 0, l.offset)
 		require.Equal(t, 30, l.indexMap.Len())
 		require.Equal(t, 30, l.items.Len())
-		require.Equal(t, 30, l.renderedItems.Len())
+		require.Equal(t, 30, len(l.itemPositions))
 		expectedLines := 0
 		for i := range 30 {
 			expectedLines += (i + 1) * 1
@@ -211,8 +208,7 @@ func TestList(t *testing.T) {
 		assert.Equal(t, expectedLines-1, end)
 		currentPosition := 0
 		for i := range 30 {
-			rItem, ok := l.renderedItems.Get(items[i].ID())
-			require.True(t, ok)
+			rItem := l.itemPositions[i]
 			assert.Equal(t, currentPosition, rItem.start)
 			assert.Equal(t, currentPosition+i, rItem.end)
 			currentPosition += i + 1
@@ -233,7 +229,7 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[10].ID(), l.selectedItem)
 
 		golden.RequireEqual(t, []byte(l.View()))
@@ -251,7 +247,7 @@ func TestList(t *testing.T) {
 		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
 		execCmd(l, l.Init())
 
-		// should select the last item
+		// should select item 10
 		assert.Equal(t, items[10].ID(), l.selectedItem)
 
 		golden.RequireEqual(t, []byte(l.View()))
@@ -365,7 +361,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 5, l.offset)
-		assert.Equal(t, 33, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 	t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
@@ -385,7 +382,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 4, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 	t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
@@ -406,7 +404,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 0, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 	t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
@@ -426,7 +425,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 	t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
@@ -445,7 +445,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 
@@ -482,7 +483,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 5, l.offset)
-		assert.Equal(t, 33, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 
@@ -503,7 +505,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 4, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 
@@ -525,7 +528,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 1, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 
@@ -546,7 +550,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 	t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
@@ -565,7 +570,8 @@ func TestListMovement(t *testing.T) {
 		viewAfter := l.View()
 		assert.Equal(t, viewBefore, viewAfter)
 		assert.Equal(t, 2, l.offset)
-		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		// With virtual scrolling, rendered height should be viewport height (10)
+		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 }