@@ -105,6 +105,10 @@ type list[T Item] struct {
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
@@ -192,6 +196,7 @@ 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](),
selectionStartCol: -1,
selectionStartLine: -1,
selectionEndLine: -1,
@@ -444,25 +449,18 @@ func (l *list[T]) View() string {
return ""
}
t := styles.CurrentTheme()
+
+ // With virtual scrolling, rendered already contains only visible content
view := l.rendered
- lines := strings.Split(view, "\n")
-
- start, end := l.viewPosition()
- viewStart := max(0, start)
- viewEnd := min(len(lines), end+1)
-
- if viewStart > viewEnd {
- viewStart = viewEnd
- }
- lines = lines[viewStart:viewEnd]
-
+
if l.resize {
- return strings.Join(lines, "\n")
+ return view
}
+
view = t.S().Base.
Height(l.height).
Width(l.width).
- Render(strings.Join(lines, "\n"))
+ Render(view)
if !l.hasSelection() {
return view
@@ -472,31 +470,26 @@ func (l *list[T]) View() string {
}
func (l *list[T]) viewPosition() (int, int) {
+ // View position in the virtual space
start, end := 0, 0
- renderedLines := lipgloss.Height(l.rendered) - 1
if l.direction == DirectionForward {
- start = max(0, l.offset)
- end = min(l.offset+l.height-1, renderedLines)
+ start = l.offset
+ if l.virtualHeight > 0 {
+ end = min(l.offset+l.height-1, l.virtualHeight-1)
+ } else {
+ end = l.offset + l.height - 1
+ }
} else {
- start = max(0, renderedLines-l.offset-l.height+1)
- end = max(0, renderedLines-l.offset)
- }
- start = min(start, end)
- return start, end
-}
-
-func (l *list[T]) recalculateItemPositions() {
- currentContentHeight := 0
- for _, item := range slices.Collect(l.items.Seq()) {
- rItem, ok := l.renderedItems.Get(item.ID())
- if !ok {
- continue
+ // For backward direction
+ if l.virtualHeight > 0 {
+ end = l.virtualHeight - l.offset - 1
+ start = max(0, end - l.height + 1)
+ } else {
+ end = 0
+ start = 0
}
- rItem.start = currentContentHeight
- rItem.end = currentContentHeight + rItem.height - 1
- l.renderedItems.Set(item.ID(), rItem)
- currentContentHeight = rItem.end + 1 + l.gap
}
+ return start, end
}
func (l *list[T]) render() tea.Cmd {
@@ -511,47 +504,21 @@ func (l *list[T]) render() tea.Cmd {
} else {
focusChangeCmd = l.blurSelectedItem()
}
- // we are not rendering the first time
- if l.rendered != "" {
- // rerender everything will mostly hit cache
- l.renderMu.Lock()
- l.rendered, _ = l.renderIterator(0, false, "")
- l.renderMu.Unlock()
- if l.direction == DirectionBackward {
- l.recalculateItemPositions()
- }
- // in the end scroll to the selected item
- if l.focused {
- l.scrollToSelection()
- }
- return focusChangeCmd
- }
+
+ // Calculate all item positions and total height
+ l.calculateItemPositions()
+
+ // Render only visible items
l.renderMu.Lock()
- rendered, finishIndex := l.renderIterator(0, true, "")
- l.rendered = rendered
+ l.rendered = l.renderVirtualScrolling()
l.renderMu.Unlock()
- // recalculate for the initial items
- if l.direction == DirectionBackward {
- l.recalculateItemPositions()
- }
- renderCmd := func() tea.Msg {
- l.offset = 0
- // render the rest
- l.renderMu.Lock()
- l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
- l.renderMu.Unlock()
- // needed for backwards
- if l.direction == DirectionBackward {
- l.recalculateItemPositions()
- }
- // in the end scroll to the selected item
- if l.focused {
- l.scrollToSelection()
- }
- return nil
+ // Scroll to selected item if focused
+ if l.focused {
+ l.scrollToSelection()
}
- return tea.Batch(focusChangeCmd, renderCmd)
+
+ return focusChangeCmd
}
func (l *list[T]) setDefaultSelected() {
@@ -573,14 +540,26 @@ func (l *list[T]) scrollToSelection() {
}
start, end := l.viewPosition()
- // item bigger or equal to the viewport do nothing
- if rItem.start <= start && rItem.end >= end {
+
+ // item bigger or equal to the viewport - show from start
+ if rItem.height >= l.height {
+ if l.direction == DirectionForward {
+ l.offset = rItem.start
+ } else {
+ if l.virtualHeight > 0 {
+ l.offset = l.virtualHeight - rItem.end
+ } else {
+ l.offset = 0
+ }
+ }
return
}
+
// if we are moving by item we want to move the offset so that the
// whole item is visible not just portions of it
if l.movingByItem {
if rItem.start >= start && rItem.end <= end {
+ // Item is fully visible, no need to scroll
return
}
defer func() { l.movingByItem = false }()
@@ -594,30 +573,27 @@ func (l *list[T]) scrollToSelection() {
}
}
- if rItem.height >= l.height {
- if l.direction == DirectionForward {
- l.offset = rItem.start
- } else {
- l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
- }
- return
- }
-
- renderedLines := lipgloss.Height(l.rendered) - 1
-
// If item is above the viewport, make it the first item
if rItem.start < start {
if l.direction == DirectionForward {
l.offset = rItem.start
} else {
- l.offset = max(0, renderedLines-rItem.start-l.height+1)
+ if l.virtualHeight > 0 {
+ l.offset = l.virtualHeight - rItem.end
+ } else {
+ l.offset = 0
+ }
}
} else if rItem.end > end {
// If item is below the viewport, make it the last item
if l.direction == DirectionForward {
- l.offset = max(0, rItem.end-l.height+1)
+ l.offset = max(0, rItem.end - l.height + 1)
} else {
- l.offset = max(0, renderedLines-rItem.end)
+ if l.virtualHeight > 0 {
+ l.offset = max(0, l.virtualHeight - rItem.start - l.height + 1)
+ } else {
+ l.offset = 0
+ }
}
}
}
@@ -795,96 +771,197 @@ func (l *list[T]) blurSelectedItem() tea.Cmd {
return tea.Batch(cmds...)
}
-// renderFragment holds updated rendered view fragments
-type renderFragment struct {
- view string
- gap int
-}
-// renderIterator renders items starting from the specific index and limits height if limitHeight != -1
-// returns the last index and the rendered content so far
-// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
-func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
- var fragments []renderFragment
- currentContentHeight := lipgloss.Height(rendered) - 1
+// calculateItemPositions calculates and caches the position and height of all items.
+func (l *list[T]) calculateItemPositions() {
+ currentHeight := 0
itemsLen := l.items.Len()
- finalIndex := itemsLen
- // first pass: accumulate all fragments to render until the height limit is
- // reached
- for i := startInx; i < itemsLen; i++ {
- if limitHeight && currentContentHeight >= l.height {
- finalIndex = i
- break
- }
- // cool way to go through the list in both directions
- inx := i
-
- if l.direction != DirectionForward {
- inx = (itemsLen - 1) - i
- }
-
- item, ok := l.items.Get(inx)
+ // Always calculate positions in forward order (logical positions)
+ for i := 0; i < itemsLen; i++ {
+ item, ok := l.items.Get(i)
if !ok {
continue
}
+ // Get or calculate item height
+ var height int
+ if cachedHeight, ok := l.itemHeights.Get(item.ID()); ok {
+ height = cachedHeight
+ } else {
+ // Calculate and cache the height
+ view := item.View()
+ height = lipgloss.Height(view)
+ l.itemHeights.Set(item.ID(), height)
+ }
+
+ // Update or create rendered item with position info
var rItem renderedItem
- if cache, ok := l.renderedItems.Get(item.ID()); ok {
- rItem = cache
+ if cached, ok := l.renderedItems.Get(item.ID()); ok {
+ rItem = cached
+ rItem.height = height
} else {
- rItem = l.renderItem(item)
- rItem.start = currentContentHeight
- rItem.end = currentContentHeight + rItem.height - 1
- l.renderedItems.Set(item.ID(), rItem)
+ rItem = renderedItem{
+ id: item.ID(),
+ height: height,
+ }
}
+
+ rItem.start = currentHeight
+ rItem.end = currentHeight + rItem.height - 1
+ l.renderedItems.Set(item.ID(), rItem)
- gap := l.gap + 1
- if inx == itemsLen-1 {
- gap = 0
+ currentHeight = rItem.end + 1
+ if i < itemsLen-1 {
+ currentHeight += l.gap
}
+ }
- fragments = append(fragments, renderFragment{view: rItem.view, gap: gap})
+ l.virtualHeight = currentHeight
+}
- currentContentHeight = rItem.end + 1 + l.gap
+// renderVirtualScrolling renders only the visible portion of the list.
+func (l *list[T]) renderVirtualScrolling() string {
+ if l.items.Len() == 0 {
+ return ""
}
- // second pass: build rendered string efficiently
+ // Calculate viewport bounds
+ viewStart, viewEnd := l.viewPosition()
+
+ // Find which items are visible
+ var visibleItems []struct {
+ item T
+ rItem renderedItem
+ index int
+ }
+
+ itemsLen := l.items.Len()
+ for i := 0; i < itemsLen; i++ {
+ item, ok := l.items.Get(i)
+ if !ok {
+ continue
+ }
+
+ rItem, ok := l.renderedItems.Get(item.ID())
+ if !ok {
+ continue
+ }
+
+ // Check if item is visible (overlaps with viewport)
+ if rItem.end >= viewStart && rItem.start <= viewEnd {
+ visibleItems = append(visibleItems, struct {
+ item T
+ rItem renderedItem
+ index int
+ }{item, rItem, i})
+ }
+
+ // Early exit if we've passed the viewport
+ if rItem.start > viewEnd {
+ break
+ }
+ }
+
+ if len(visibleItems) == 0 {
+ // Return empty lines to maintain height
+ var lines []string
+ for i := 0; i < l.height; i++ {
+ lines = append(lines, "")
+ }
+ return strings.Join(lines, "\n")
+ }
+
+ // Render visible items
var b strings.Builder
- if l.direction == DirectionForward {
- b.WriteString(rendered)
- for _, f := range fragments {
- b.WriteString(f.view)
- for range f.gap {
+ currentLine := viewStart
+
+ // Handle first visible item
+ firstVisible := visibleItems[0]
+ if firstVisible.rItem.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 skipLines >= 0 && skipLines < len(lines) {
+ for i := skipLines; i < len(lines); i++ {
+ if currentLine > viewEnd {
+ break
+ }
+ b.WriteString(lines[i])
+ b.WriteByte('\n')
+ currentLine++
+ }
+ }
+ }
+ } else if firstVisible.rItem.start > viewStart {
+ // Add empty lines before first item
+ for currentLine < firstVisible.rItem.start && currentLine <= viewEnd {
+ if b.Len() > 0 {
b.WriteByte('\n')
}
+ currentLine++
}
-
- return b.String(), finalIndex
}
-
- // iterate backwards as fragments are in reversed order
- for i := len(fragments) - 1; i >= 0; i-- {
- f := fragments[i]
- b.WriteString(f.view)
- for range f.gap {
+
+ // Render fully visible items
+ for i, vis := range visibleItems {
+ if currentLine > viewEnd {
+ break
+ }
+
+ // Skip first item if we already rendered it partially
+ if i == 0 && firstVisible.rItem.start < viewStart {
+ 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++
+ }
+ }
+
+ // Render item or use cache
+ var view string
+ if cached, ok := l.renderedItems.Get(vis.item.ID()); ok && cached.view != "" {
+ view = cached.view
+ } else {
+ view = vis.item.View()
+ // Update cache
+ rItem := vis.rItem
+ rItem.view = view
+ l.renderedItems.Set(vis.item.ID(), rItem)
+ }
+
+ // Handle partial rendering if item extends beyond viewport
+ lines := strings.Split(view, "\n")
+ for _, line := range lines {
+ if currentLine > viewEnd {
+ break
+ }
+ if b.Len() > 0 {
+ b.WriteByte('\n')
+ }
+ b.WriteString(line)
+ currentLine++
+ }
+ }
+
+ // Fill remaining viewport with empty lines if needed
+ for currentLine <= viewEnd {
+ if b.Len() > 0 {
b.WriteByte('\n')
}
+ currentLine++
}
- b.WriteString(rendered)
-
- return b.String(), finalIndex
+
+ return b.String()
}
-func (l *list[T]) renderItem(item Item) renderedItem {
- view := item.View()
- return renderedItem{
- id: item.ID(),
- view: view,
- height: lipgloss.Height(view),
- }
-}
+
// AppendItem implements List.
func (l *list[T]) AppendItem(item T) tea.Cmd {
@@ -922,7 +999,9 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
if l.items.Len() > 1 {
newLines += l.gap
}
- l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+ if l.virtualHeight > 0 {
+ l.offset = min(l.virtualHeight-1, l.offset+newLines)
+ }
}
}
}
@@ -961,7 +1040,7 @@ func (l *list[T]) DeleteItem(id string) tea.Cmd {
}
cmd := l.render()
if l.rendered != "" {
- renderedHeight := lipgloss.Height(l.rendered)
+ renderedHeight := l.virtualHeight
if renderedHeight <= l.height {
l.offset = 0
} else {
@@ -1012,7 +1091,7 @@ func (l *list[T]) Items() []T {
}
func (l *list[T]) incrementOffset(n int) {
- renderedHeight := lipgloss.Height(l.rendered)
+ renderedHeight := l.virtualHeight
// no need for offset
if renderedHeight <= l.height {
return
@@ -1129,7 +1208,9 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
if l.items.Len() > 1 {
newLines += l.gap
}
- l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+ if l.virtualHeight > 0 {
+ l.offset = min(l.virtualHeight-1, l.offset+newLines)
+ }
}
}
}
@@ -1236,6 +1317,8 @@ func (l *list[T]) reset(selectedItem string) tea.Cmd {
l.selectedItem = selectedItem
l.indexMap = csync.NewMap[string, int]()
l.renderedItems = csync.NewMap[string, renderedItem]()
+ l.itemHeights = csync.NewMap[string, int]()
+ l.virtualHeight = 0
for inx, item := range slices.Collect(l.items.Seq()) {
l.indexMap.Set(item.ID(), inx)
if l.width > 0 && l.height > 0 {
@@ -1266,10 +1349,17 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
oldItem, hasOldItem := l.renderedItems.Get(id)
oldPosition := l.offset
if l.direction == DirectionBackward {
- oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
+ if l.virtualHeight > 0 {
+ oldPosition = (l.virtualHeight - 1) - l.offset
+ } else {
+ oldPosition = 0
+ }
}
+ // Clear caches for this item
l.renderedItems.Del(id)
+ l.itemHeights.Del(id)
+
cmd := l.render()
// need to check for nil because of sequence not handling nil
@@ -1283,14 +1373,22 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
newItem, ok := l.renderedItems.Get(item.ID())
if ok {
newLines := newItem.height - oldItem.height
- l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+ if l.virtualHeight > 0 {
+ l.offset = util.Clamp(l.offset+newLines, 0, l.virtualHeight-1)
+ } else {
+ l.offset = 0
+ }
}
}
} else if hasOldItem && l.offset > oldItem.start {
newItem, ok := l.renderedItems.Get(item.ID())
if ok {
newLines := newItem.height - oldItem.height
- l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+ if l.virtualHeight > 0 {
+ l.offset = util.Clamp(l.offset+newLines, 0, l.virtualHeight-1)
+ } else {
+ l.offset = 0
+ }
}
}
}