List performance improvements (#1325)

Kujtim Hoxha and Raphael Amorim created

Co-authored-by: Raphael Amorim <rapha850@gmail.com>

Change summary

internal/tui/exp/list/filterable.go       |   4 
internal/tui/exp/list/filterable_group.go |   5 
internal/tui/exp/list/filterable_test.go  |   2 
internal/tui/exp/list/grouped.go          |  13 
internal/tui/exp/list/list.go             | 566 ++++++++++++++++--------
internal/tui/exp/list/list_test.go        |  64 +-
6 files changed, 415 insertions(+), 239 deletions(-)

Detailed changes

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

@@ -102,7 +102,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
 	f.list = New(items, f.listOptions...).(*list[T])
 
 	f.updateKeyMaps()
-	f.items = slices.Collect(f.list.items.Seq())
+	f.items = f.list.items
 
 	if f.inputHidden {
 		return f
@@ -243,7 +243,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
 		}
 	}
 
-	f.selectedItem = ""
+	f.selectedItemIdx = -1
 	if query == "" || len(f.items) == 0 {
 		return f.list.SetItems(f.items)
 	}

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

@@ -2,7 +2,6 @@ package list
 
 import (
 	"regexp"
-	"slices"
 	"sort"
 	"strings"
 
@@ -183,7 +182,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
 
 func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
 	var cmds []tea.Cmd
-	for _, item := range slices.Collect(f.items.Seq()) {
+	for _, item := range f.items {
 		if i, ok := any(item).(layout.Focusable); ok {
 			cmds = append(cmds, i.Blur())
 		}
@@ -253,7 +252,7 @@ func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string
 
 func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
 	cmds := f.clearItemState()
-	f.selectedItem = ""
+	f.selectedItemIdx = -1
 
 	if query == "" {
 		return f.groupedList.SetGroups(f.groups)

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

@@ -29,7 +29,7 @@ func TestFilterableList(t *testing.T) {
 			cmd()
 		}
 
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 }

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

@@ -1,10 +1,7 @@
 package list
 
 import (
-	"slices"
-
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -40,9 +37,9 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
 			keyMap:    DefaultKeyMap(),
 			focused:   true,
 		},
-		items:         csync.NewSlice[Item](),
-		indexMap:      csync.NewMap[string, int](),
-		renderedItems: csync.NewMap[string, renderedItem](),
+		items:         []Item{},
+		indexMap:      make(map[string]int),
+		renderedItems: make(map[string]renderedItem),
 	}
 	for _, opt := range opts {
 		opt(list.confOptions)
@@ -85,13 +82,13 @@ func (g *groupedList[T]) convertItems() {
 			items = append(items, g)
 		}
 	}
-	g.items.SetSlice(items)
+	g.items = items
 }
 
 func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
 	g.groups = groups
 	g.convertItems()
-	return g.SetItems(slices.Collect(g.items.Seq()))
+	return g.SetItems(g.items)
 }
 
 func (g *groupedList[T]) Groups() []Group[T] {

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

@@ -1,13 +1,11 @@
 package list
 
 import (
-	"slices"
 	"strings"
 	"sync"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/csync"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
@@ -19,6 +17,25 @@ import (
 	"github.com/rivo/uniseg"
 )
 
+const maxGapSize = 100
+
+var newlineBuffer = strings.Repeat("\n", maxGapSize)
+
+var (
+	specialCharsMap  map[string]struct{}
+	specialCharsOnce sync.Once
+)
+
+func getSpecialCharsMap() map[string]struct{} {
+	specialCharsOnce.Do(func() {
+		specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
+		for _, icon := range styles.SelectionIgnoreIcons {
+			specialCharsMap[icon] = struct{}{}
+		}
+	})
+	return specialCharsMap
+}
+
 type Item interface {
 	util.Model
 	layout.Sizeable
@@ -35,7 +52,6 @@ type List[T Item] interface {
 	layout.Sizeable
 	layout.Focusable
 
-	// Just change state
 	MoveUp(int) tea.Cmd
 	MoveDown(int) tea.Cmd
 	GoToTop() tea.Cmd
@@ -69,11 +85,10 @@ const (
 
 const (
 	ItemNotFound              = -1
-	ViewportDefaultScrollSize = 2
+	ViewportDefaultScrollSize = 5
 )
 
 type renderedItem struct {
-	id     string
 	view   string
 	height int
 	start  int
@@ -81,16 +96,16 @@ type renderedItem struct {
 }
 
 type confOptions struct {
-	width, height int
-	gap           int
-	// if you are at the last item and go down it will wrap to the top
-	wrap         bool
-	keyMap       KeyMap
-	direction    direction
-	selectedItem string
-	focused      bool
-	resize       bool
-	enableMouse  bool
+	width, height   int
+	gap             int
+	wrap            bool
+	keyMap          KeyMap
+	direction       direction
+	selectedItemIdx int    // Index of selected item (-1 if none)
+	selectedItemID  string // Temporary storage for WithSelectedItem (resolved in New())
+	focused         bool
+	resize          bool
+	enableMouse     bool
 }
 
 type list[T Item] struct {
@@ -98,19 +113,24 @@ type list[T Item] struct {
 
 	offset int
 
-	indexMap *csync.Map[string, int]
-	items    *csync.Slice[T]
+	indexMap      map[string]int
+	items         []T
+	renderedItems map[string]renderedItem
 
-	renderedItems *csync.Map[string, renderedItem]
+	rendered       string
+	renderedHeight int   // cached height of rendered content
+	lineOffsets    []int // cached byte offsets for each line (for fast slicing)
 
-	renderMu sync.Mutex
-	rendered string
+	cachedView       string
+	cachedViewOffset int
+	cachedViewDirty  bool
 
-	movingByItem       bool
-	selectionStartCol  int
-	selectionStartLine int
-	selectionEndCol    int
-	selectionEndLine   int
+	movingByItem        bool
+	prevSelectedItemIdx int // Index of previously selected item (-1 if none)
+	selectionStartCol   int
+	selectionStartLine  int
+	selectionEndCol     int
+	selectionEndLine    int
 
 	selectionActive bool
 }
@@ -149,7 +169,7 @@ func WithDirectionBackward() ListOption {
 // WithSelectedItem sets the initially selected item in the list.
 func WithSelectedItem(id string) ListOption {
 	return func(l *confOptions) {
-		l.selectedItem = id
+		l.selectedItemID = id // Will be resolved to index in New()
 	}
 }
 
@@ -186,17 +206,19 @@ func WithEnableMouse() ListOption {
 func New[T Item](items []T, opts ...ListOption) List[T] {
 	list := &list[T]{
 		confOptions: &confOptions{
-			direction: DirectionForward,
-			keyMap:    DefaultKeyMap(),
-			focused:   true,
+			direction:       DirectionForward,
+			keyMap:          DefaultKeyMap(),
+			focused:         true,
+			selectedItemIdx: -1,
 		},
-		items:              csync.NewSliceFrom(items),
-		indexMap:           csync.NewMap[string, int](),
-		renderedItems:      csync.NewMap[string, renderedItem](),
-		selectionStartCol:  -1,
-		selectionStartLine: -1,
-		selectionEndLine:   -1,
-		selectionEndCol:    -1,
+		items:               items,
+		indexMap:            make(map[string]int, len(items)),
+		renderedItems:       make(map[string]renderedItem),
+		prevSelectedItemIdx: -1,
+		selectionStartCol:   -1,
+		selectionStartLine:  -1,
+		selectionEndLine:    -1,
+		selectionEndCol:     -1,
 	}
 	for _, opt := range opts {
 		opt(list.confOptions)
@@ -206,8 +228,17 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
 		if i, ok := any(item).(Indexable); ok {
 			i.SetIndex(inx)
 		}
-		list.indexMap.Set(item.ID(), inx)
+		list.indexMap[item.ID()] = inx
+	}
+
+	// Resolve selectedItemID to selectedItemIdx if specified
+	if list.selectedItemID != "" {
+		if idx, ok := list.indexMap[list.selectedItemID]; ok {
+			list.selectedItemIdx = idx
+		}
+		list.selectedItemID = "" // Clear temporary storage
 	}
+
 	return list
 }
 
@@ -225,10 +256,25 @@ func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 		return l, nil
 	case anim.StepMsg:
+		// Fast path: if no items, skip processing
+		if len(l.items) == 0 {
+			return l, nil
+		}
+
+		// Fast path: check if ANY items are actually spinning before processing
+		if !l.hasSpinningItems() {
+			return l, nil
+		}
+
 		var cmds []tea.Cmd
-		for _, item := range slices.Collect(l.items.Seq()) {
-			if i, ok := any(item).(HasAnim); ok && i.Spinning() {
-				updated, cmd := i.Update(msg)
+		itemsLen := len(l.items)
+		for i := range itemsLen {
+			if i >= len(l.items) {
+				continue
+			}
+			item := l.items[i]
+			if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
+				updated, cmd := animItem.Update(msg)
 				cmds = append(cmds, cmd)
 				if u, ok := updated.(T); ok {
 					cmds = append(cmds, l.UpdateItem(u.ID(), u))
@@ -288,8 +334,16 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd)
 	return l, cmd
 }
 
-// selectionView renders the highlighted selection in the view and returns it
-// as a string. If textOnly is true, it won't render any styles.
+func (l *list[T]) hasSpinningItems() bool {
+	for i := range l.items {
+		item := l.items[i]
+		if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
+			return true
+		}
+	}
+	return false
+}
+
 func (l *list[T]) selectionView(view string, textOnly bool) string {
 	t := styles.CurrentTheme()
 	area := uv.Rect(0, 0, l.width, l.height)
@@ -302,10 +356,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 	}
 	selArea = selArea.Canon()
 
-	specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
-	for _, icon := range styles.SelectionIgnoreIcons {
-		specialChars[icon] = true
-	}
+	specialChars := getSpecialCharsMap()
 
 	isNonWhitespace := func(r rune) bool {
 		return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
@@ -366,7 +417,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 				}
 
 				char := rune(cellStr[0])
-				isSpecial := specialChars[cellStr]
+				_, isSpecial := specialChars[cellStr]
 
 				if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
 					if bounds.start == -1 {
@@ -409,7 +460,10 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 			}
 
 			cellStr := cell.String()
-			if len(cellStr) > 0 && !specialChars[cellStr] {
+			if len(cellStr) > 0 {
+				if _, isSpecial := specialChars[cellStr]; isSpecial {
+					continue
+				}
 				if textOnly {
 					// Collect selected text without styles
 					selectedText.WriteString(cell.String())
@@ -439,33 +493,40 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
 	return scr.Render()
 }
 
-// View implements List.
 func (l *list[T]) View() string {
 	if l.height <= 0 || l.width <= 0 {
 		return ""
 	}
+
+	if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
+		return l.cachedView
+	}
+
 	t := styles.CurrentTheme()
-	view := l.rendered
-	lines := strings.Split(view, "\n")
 
 	start, end := l.viewPosition()
 	viewStart := max(0, start)
-	viewEnd := min(len(lines), end+1)
+	viewEnd := end
 
 	if viewStart > viewEnd {
-		viewStart = viewEnd
+		return ""
 	}
-	lines = lines[viewStart:viewEnd]
+
+	view := l.getLines(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() {
+		l.cachedView = view
+		l.cachedViewOffset = l.offset
+		l.cachedViewDirty = false
 		return view
 	}
 
@@ -474,7 +535,7 @@ func (l *list[T]) View() string {
 
 func (l *list[T]) viewPosition() (int, int) {
 	start, end := 0, 0
-	renderedLines := lipgloss.Height(l.rendered) - 1
+	renderedLines := l.renderedHeight - 1
 	if l.direction == DirectionForward {
 		start = max(0, l.offset)
 		end = min(l.offset+l.height-1, renderedLines)
@@ -486,22 +547,114 @@ func (l *list[T]) viewPosition() (int, int) {
 	return start, end
 }
 
+func (l *list[T]) setRendered(rendered string) {
+	l.rendered = rendered
+	l.renderedHeight = lipgloss.Height(rendered)
+	l.cachedViewDirty = true // Mark view cache as dirty
+
+	if len(rendered) > 0 {
+		l.lineOffsets = make([]int, 0, l.renderedHeight)
+		l.lineOffsets = append(l.lineOffsets, 0)
+
+		offset := 0
+		for {
+			idx := strings.IndexByte(rendered[offset:], '\n')
+			if idx == -1 {
+				break
+			}
+			offset += idx + 1
+			l.lineOffsets = append(l.lineOffsets, offset)
+		}
+	} else {
+		l.lineOffsets = nil
+	}
+}
+
+func (l *list[T]) getLines(start, end int) string {
+	if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
+		return ""
+	}
+
+	if end >= len(l.lineOffsets) {
+		end = len(l.lineOffsets) - 1
+	}
+	if start > end {
+		return ""
+	}
+
+	startOffset := l.lineOffsets[start]
+	var endOffset int
+	if end+1 < len(l.lineOffsets) {
+		endOffset = l.lineOffsets[end+1] - 1
+	} else {
+		endOffset = len(l.rendered)
+	}
+
+	if startOffset >= len(l.rendered) {
+		return ""
+	}
+	endOffset = min(endOffset, len(l.rendered))
+
+	return l.rendered[startOffset:endOffset]
+}
+
+// getLine returns a single line from the rendered content using lineOffsets.
+// This avoids allocating a new string for each line like strings.Split does.
+func (l *list[T]) getLine(index int) string {
+	if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
+		return ""
+	}
+
+	startOffset := l.lineOffsets[index]
+	var endOffset int
+	if index+1 < len(l.lineOffsets) {
+		endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
+	} else {
+		endOffset = len(l.rendered)
+	}
+
+	if startOffset >= len(l.rendered) {
+		return ""
+	}
+	endOffset = min(endOffset, len(l.rendered))
+
+	return l.rendered[startOffset:endOffset]
+}
+
+// lineCount returns the number of lines in the rendered content.
+func (l *list[T]) lineCount() int {
+	return len(l.lineOffsets)
+}
+
 func (l *list[T]) recalculateItemPositions() {
-	currentContentHeight := 0
-	for _, item := range slices.Collect(l.items.Seq()) {
-		rItem, ok := l.renderedItems.Get(item.ID())
+	l.recalculateItemPositionsFrom(0)
+}
+
+func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
+	var currentContentHeight int
+
+	if startIdx > 0 && startIdx <= len(l.items) {
+		prevItem := l.items[startIdx-1]
+		if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
+			currentContentHeight = rItem.end + 1 + l.gap
+		}
+	}
+
+	for i := startIdx; i < len(l.items); i++ {
+		item := l.items[i]
+		rItem, ok := l.renderedItems[item.ID()]
 		if !ok {
 			continue
 		}
 		rItem.start = currentContentHeight
 		rItem.end = currentContentHeight + rItem.height - 1
-		l.renderedItems.Set(item.ID(), rItem)
+		l.renderedItems[item.ID()] = rItem
 		currentContentHeight = rItem.end + 1 + l.gap
 	}
 }
 
 func (l *list[T]) render() tea.Cmd {
-	if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
+	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
 		return nil
 	}
 	l.setDefaultSelected()
@@ -512,51 +665,38 @@ 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()
+		rendered, _ := l.renderIterator(0, false, "")
+		l.setRendered(rendered)
 		if l.direction == DirectionBackward {
 			l.recalculateItemPositions()
 		}
-		// in the end scroll to the selected item
 		if l.focused {
 			l.scrollToSelection()
 		}
 		return focusChangeCmd
 	}
-	l.renderMu.Lock()
 	rendered, finishIndex := l.renderIterator(0, true, "")
-	l.rendered = rendered
-	l.renderMu.Unlock()
-	// recalculate for the initial items
+	l.setRendered(rendered)
 	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
+	l.offset = 0
+	rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
+	l.setRendered(rendered)
+	if l.direction == DirectionBackward {
+		l.recalculateItemPositions()
 	}
-	return tea.Batch(focusChangeCmd, renderCmd)
+	if l.focused {
+		l.scrollToSelection()
+	}
+
+	return focusChangeCmd
 }
 
 func (l *list[T]) setDefaultSelected() {
-	if l.selectedItem == "" {
+	if l.selectedItemIdx < 0 {
 		if l.direction == DirectionForward {
 			l.selectFirstItem()
 		} else {
@@ -566,27 +706,29 @@ func (l *list[T]) setDefaultSelected() {
 }
 
 func (l *list[T]) scrollToSelection() {
-	rItem, ok := l.renderedItems.Get(l.selectedItem)
+	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
+		l.selectedItemIdx = -1
+		l.setDefaultSelected()
+		return
+	}
+	item := l.items[l.selectedItemIdx]
+	rItem, ok := l.renderedItems[item.ID()]
 	if !ok {
-		l.selectedItem = ""
+		l.selectedItemIdx = -1
 		l.setDefaultSelected()
 		return
 	}
 
 	start, end := l.viewPosition()
-	// item bigger or equal to the viewport do nothing
 	if rItem.start <= start && rItem.end >= end {
 		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 {
 			return
 		}
 		defer func() { l.movingByItem = false }()
 	} else {
-		// item already in view do nothing
 		if rItem.start >= start && rItem.start <= end {
 			return
 		}
@@ -599,14 +741,13 @@ func (l *list[T]) scrollToSelection() {
 		if l.direction == DirectionForward {
 			l.offset = rItem.start
 		} else {
-			l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
+			l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
 		}
 		return
 	}
 
-	renderedLines := lipgloss.Height(l.rendered) - 1
+	renderedLines := l.renderedHeight - 1
 
-	// If item is above the viewport, make it the first item
 	if rItem.start < start {
 		if l.direction == DirectionForward {
 			l.offset = rItem.start
@@ -614,7 +755,6 @@ func (l *list[T]) scrollToSelection() {
 			l.offset = max(0, renderedLines-rItem.start-l.height+1)
 		}
 	} 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)
 		} else {
@@ -624,7 +764,11 @@ func (l *list[T]) scrollToSelection() {
 }
 
 func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
-	rItem, ok := l.renderedItems.Get(l.selectedItem)
+	if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
+		return nil
+	}
+	item := l.items[l.selectedItemIdx]
+	rItem, ok := l.renderedItems[item.ID()]
 	if !ok {
 		return nil
 	}
@@ -643,64 +787,60 @@ 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
-		}
+		inx := l.selectedItemIdx
 		for {
 			inx = l.firstSelectableItemBelow(inx)
 			if inx == ItemNotFound {
 				return nil
 			}
-			item, ok := l.items.Get(inx)
-			if !ok {
+			if inx < 0 || inx >= len(l.items) {
 				continue
 			}
-			renderedItem, ok := l.renderedItems.Get(item.ID())
+
+			item := l.items[inx]
+			renderedItem, ok := l.renderedItems[item.ID()]
 			if !ok {
 				continue
 			}
 
 			// If the item is bigger than the viewport, select it
 			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				return l.render()
 			}
 			// item is in the view
 			if renderedItem.start >= start && renderedItem.start <= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				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
-		}
+		inx := l.selectedItemIdx
 		for {
 			inx = l.firstSelectableItemAbove(inx)
 			if inx == ItemNotFound {
 				return nil
 			}
-			item, ok := l.items.Get(inx)
-			if !ok {
+			if inx < 0 || inx >= len(l.items) {
 				continue
 			}
-			renderedItem, ok := l.renderedItems.Get(item.ID())
+
+			item := l.items[inx]
+			renderedItem, ok := l.renderedItems[item.ID()]
 			if !ok {
 				continue
 			}
 
 			// If the item is bigger than the viewport, select it
 			if renderedItem.start <= start && renderedItem.end >= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				return l.render()
 			}
 			// item is in the view
 			if renderedItem.end >= start && renderedItem.end <= end {
-				l.selectedItem = renderedItem.id
+				l.selectedItemIdx = inx
 				return l.render()
 			}
 		}
@@ -711,46 +851,42 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
 func (l *list[T]) selectFirstItem() {
 	inx := l.firstSelectableItemBelow(-1)
 	if inx != ItemNotFound {
-		item, ok := l.items.Get(inx)
-		if ok {
-			l.selectedItem = item.ID()
-		}
+		l.selectedItemIdx = inx
 	}
 }
 
 func (l *list[T]) selectLastItem() {
-	inx := l.firstSelectableItemAbove(l.items.Len())
+	inx := l.firstSelectableItemAbove(len(l.items))
 	if inx != ItemNotFound {
-		item, ok := l.items.Get(inx)
-		if ok {
-			l.selectedItem = item.ID()
-		}
+		l.selectedItemIdx = inx
 	}
 }
 
 func (l *list[T]) firstSelectableItemAbove(inx int) int {
 	for i := inx - 1; i >= 0; i-- {
-		item, ok := l.items.Get(i)
-		if !ok {
+		if i < 0 || i >= len(l.items) {
 			continue
 		}
+
+		item := l.items[i]
 		if _, ok := any(item).(layout.Focusable); ok {
 			return i
 		}
 	}
 	if inx == 0 && l.wrap {
-		return l.firstSelectableItemAbove(l.items.Len())
+		return l.firstSelectableItemAbove(len(l.items))
 	}
 	return ItemNotFound
 }
 
 func (l *list[T]) firstSelectableItemBelow(inx int) int {
-	itemsLen := l.items.Len()
+	itemsLen := len(l.items)
 	for i := inx + 1; i < itemsLen; i++ {
-		item, ok := l.items.Get(i)
-		if !ok {
+		if i < 0 || i >= len(l.items) {
 			continue
 		}
+
+		item := l.items[i]
 		if _, ok := any(item).(layout.Focusable); ok {
 			return i
 		}
@@ -762,38 +898,52 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
 }
 
 func (l *list[T]) focusSelectedItem() tea.Cmd {
-	if l.selectedItem == "" || !l.focused {
+	if l.selectedItemIdx < 0 || !l.focused {
 		return nil
 	}
-	var cmds []tea.Cmd
-	for _, item := range slices.Collect(l.items.Seq()) {
-		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())
-			} else if item.ID() != l.selectedItem && f.IsFocused() {
-				cmds = append(cmds, f.Blur())
-				l.renderedItems.Del(item.ID())
-			}
+	// Pre-allocate with expected capacity
+	cmds := make([]tea.Cmd, 0, 2)
+
+	// Blur the previously selected item if it's different
+	if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
+		prevItem := l.items[l.prevSelectedItemIdx]
+		if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
+			cmds = append(cmds, f.Blur())
+			// Mark cache as needing update, but don't delete yet
+			// This allows the render to potentially reuse it
+			delete(l.renderedItems, prevItem.ID())
 		}
 	}
+
+	// Focus the currently selected item
+	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
+		item := l.items[l.selectedItemIdx]
+		if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
+			cmds = append(cmds, f.Focus())
+			// Mark for re-render
+			delete(l.renderedItems, item.ID())
+		}
+	}
+
+	l.prevSelectedItemIdx = l.selectedItemIdx
 	return tea.Batch(cmds...)
 }
 
 func (l *list[T]) blurSelectedItem() tea.Cmd {
-	if l.selectedItem == "" || l.focused {
+	if l.selectedItemIdx < 0 || l.focused {
 		return nil
 	}
-	var cmds []tea.Cmd
-	for _, item := range slices.Collect(l.items.Seq()) {
-		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())
-			}
+
+	// Blur the currently selected item
+	if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
+		item := l.items[l.selectedItemIdx]
+		if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
+			delete(l.renderedItems, item.ID())
+			return f.Blur()
 		}
 	}
-	return tea.Batch(cmds...)
+
+	return nil
 }
 
 // renderFragment holds updated rendered view fragments
@@ -806,10 +956,15 @@ type renderFragment struct {
 // 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
+	// Pre-allocate fragments with expected capacity
+	itemsLen := len(l.items)
+	expectedFragments := itemsLen - startInx
+	if limitHeight && l.height > 0 {
+		expectedFragments = min(expectedFragments, l.height)
+	}
+	fragments := make([]renderFragment, 0, expectedFragments)
 
 	currentContentHeight := lipgloss.Height(rendered) - 1
-	itemsLen := l.items.Len()
 	finalIndex := itemsLen
 
 	// first pass: accumulate all fragments to render until the height limit is
@@ -826,19 +981,20 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 			inx = (itemsLen - 1) - i
 		}
 
-		item, ok := l.items.Get(inx)
-		if !ok {
+		if inx < 0 || inx >= len(l.items) {
 			continue
 		}
 
+		item := l.items[inx]
+
 		var rItem renderedItem
-		if cache, ok := l.renderedItems.Get(item.ID()); ok {
+		if cache, ok := l.renderedItems[item.ID()]; ok {
 			rItem = cache
 		} else {
 			rItem = l.renderItem(item)
 			rItem.start = currentContentHeight
 			rItem.end = currentContentHeight + rItem.height - 1
-			l.renderedItems.Set(item.ID(), rItem)
+			l.renderedItems[item.ID()] = rItem
 		}
 
 		gap := l.gap + 1
@@ -853,12 +1009,26 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 
 	// second pass: build rendered string efficiently
 	var b strings.Builder
+
+	// Pre-size the builder to reduce allocations
+	estimatedSize := len(rendered)
+	for _, f := range fragments {
+		estimatedSize += len(f.view) + f.gap
+	}
+	b.Grow(estimatedSize)
+
 	if l.direction == DirectionForward {
 		b.WriteString(rendered)
-		for _, f := range fragments {
+		for i := range fragments {
+			f := &fragments[i]
 			b.WriteString(f.view)
-			for range f.gap {
-				b.WriteByte('\n')
+			// Optimized gap writing using pre-allocated buffer
+			if f.gap > 0 {
+				if f.gap <= maxGapSize {
+					b.WriteString(newlineBuffer[:f.gap])
+				} else {
+					b.WriteString(strings.Repeat("\n", f.gap))
+				}
 			}
 		}
 
@@ -867,10 +1037,15 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 
 	// iterate backwards as fragments are in reversed order
 	for i := len(fragments) - 1; i >= 0; i-- {
-		f := fragments[i]
+		f := &fragments[i]
 		b.WriteString(f.view)
-		for range f.gap {
-			b.WriteByte('\n')
+		// Optimized gap writing using pre-allocated buffer
+		if f.gap > 0 {
+			if f.gap <= maxGapSize {
+				b.WriteString(newlineBuffer[:f.gap])
+			} else {
+				b.WriteString(strings.Repeat("\n", f.gap))
+			}
 		}
 	}
 	b.WriteString(rendered)
@@ -881,7 +1056,6 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
 func (l *list[T]) renderItem(item Item) renderedItem {
 	view := item.View()
 	return renderedItem{
-		id:     item.ID(),
 		view:   view,
 		height: lipgloss.Height(view),
 	}
@@ -889,17 +1063,17 @@ func (l *list[T]) renderItem(item Item) renderedItem {
 
 // AppendItem implements List.
 func (l *list[T]) AppendItem(item T) tea.Cmd {
-	var cmds []tea.Cmd
+	// Pre-allocate with expected capacity
+	cmds := make([]tea.Cmd, 0, 4)
 	cmd := item.Init()
 	if cmd != nil {
 		cmds = append(cmds, cmd)
 	}
 
-	l.items.Append(item)
-	l.indexMap = csync.NewMap[string, int]()
-	for inx, item := range slices.Collect(l.items.Seq()) {
-		l.indexMap.Set(item.ID(), inx)
-	}
+	newIndex := len(l.items)
+	l.items = append(l.items, item)
+	l.indexMap[item.ID()] = newIndex
+
 	if l.width > 0 && l.height > 0 {
 		cmd = item.SetSize(l.width, l.height)
 		if cmd != nil {
@@ -917,13 +1091,13 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
 				cmds = append(cmds, cmd)
 			}
 		} else {
-			newItem, ok := l.renderedItems.Get(item.ID())
+			newItem, ok := l.renderedItems[item.ID()]
 			if ok {
 				newLines := newItem.height
-				if l.items.Len() > 1 {
+				if len(l.items) > 1 {
 					newLines += l.gap
 				}
-				l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+				l.offset = min(l.renderedHeight-1, l.offset+newLines)
 			}
 		}
 	}
@@ -938,35 +1112,41 @@ func (l *list[T]) Blur() tea.Cmd {
 
 // DeleteItem implements List.
 func (l *list[T]) DeleteItem(id string) tea.Cmd {
-	inx, ok := l.indexMap.Get(id)
+	inx, ok := l.indexMap[id]
 	if !ok {
 		return nil
 	}
-	l.items.Delete(inx)
-	l.renderedItems.Del(id)
-	for inx, item := range slices.Collect(l.items.Seq()) {
-		l.indexMap.Set(item.ID(), inx)
+	l.items = append(l.items[:inx], l.items[inx+1:]...)
+	delete(l.renderedItems, id)
+	delete(l.indexMap, id)
+
+	// Only update indices for items after the deleted one
+	itemsLen := len(l.items)
+	for i := inx; i < itemsLen; i++ {
+		if i >= 0 && i < len(l.items) {
+			item := l.items[i]
+			l.indexMap[item.ID()] = i
+		}
 	}
 
-	if l.selectedItem == id {
+	// Adjust selectedItemIdx if the deleted item was selected or before it
+	if l.selectedItemIdx == inx {
+		// Deleted item was selected, select the previous item if possible
 		if inx > 0 {
-			item, ok := l.items.Get(inx - 1)
-			if ok {
-				l.selectedItem = item.ID()
-			} else {
-				l.selectedItem = ""
-			}
+			l.selectedItemIdx = inx - 1
 		} else {
-			l.selectedItem = ""
+			l.selectedItemIdx = -1
 		}
+	} else if l.selectedItemIdx > inx {
+		// Selected item is after the deleted one, shift index down
+		l.selectedItemIdx--
 	}
 	cmd := l.render()
 	if l.rendered != "" {
-		renderedHeight := lipgloss.Height(l.rendered)
-		if renderedHeight <= l.height {
+		if l.renderedHeight <= l.height {
 			l.offset = 0
 		} else {
-			maxOffset := renderedHeight - l.height
+			maxOffset := l.renderedHeight - l.height
 			if l.offset > maxOffset {
 				l.offset = maxOffset
 			}
@@ -989,7 +1169,7 @@ func (l *list[T]) GetSize() (int, int) {
 // GoToBottom implements List.
 func (l *list[T]) GoToBottom() tea.Cmd {
 	l.offset = 0
-	l.selectedItem = ""
+	l.selectedItemIdx = -1
 	l.direction = DirectionBackward
 	return l.render()
 }
@@ -997,7 +1177,7 @@ func (l *list[T]) GoToBottom() tea.Cmd {
 // GoToTop implements List.
 func (l *list[T]) GoToTop() tea.Cmd {
 	l.offset = 0
-	l.selectedItem = ""
+	l.selectedItemIdx = -1
 	l.direction = DirectionForward
 	return l.render()
 }

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

@@ -28,18 +28,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		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.indexMap))
+		require.Equal(t, 5, len(l.items))
+		require.Equal(t, 5, len(l.renderedItems))
 		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())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -58,18 +58,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[4].ID(), l.selectedItem)
+		assert.Equal(t, 4, l.selectedItemIdx)
 		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.indexMap))
+		require.Equal(t, 5, len(l.items))
+		require.Equal(t, 5, len(l.renderedItems))
 		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())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -89,18 +89,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		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.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		assert.Equal(t, 30, 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())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -119,18 +119,18 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 29, l.selectedItemIdx)
 		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.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		assert.Equal(t, 30, 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())
+			item, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, i, item.start)
 			assert.Equal(t, i, item.end)
@@ -152,11 +152,11 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.selectedItemIdx)
 		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.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		expectedLines := 0
 		for i := range 30 {
 			expectedLines += (i + 1) * 1
@@ -168,7 +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())
+			rItem, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, currentPosition, rItem.start)
 			assert.Equal(t, currentPosition+i, rItem.end)
@@ -190,11 +190,11 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 29, l.selectedItemIdx)
 		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.indexMap))
+		require.Equal(t, 30, len(l.items))
+		require.Equal(t, 30, len(l.renderedItems))
 		expectedLines := 0
 		for i := range 30 {
 			expectedLines += (i + 1) * 1
@@ -206,7 +206,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())
+			rItem, ok := l.renderedItems[items[i].ID()]
 			require.True(t, ok)
 			assert.Equal(t, currentPosition, rItem.start)
 			assert.Equal(t, currentPosition+i, rItem.end)
@@ -229,7 +229,7 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[10].ID(), l.selectedItem)
+		assert.Equal(t, 10, l.selectedItemIdx)
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})
@@ -247,7 +247,7 @@ func TestList(t *testing.T) {
 		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, items[10].ID(), l.selectedItem)
+		assert.Equal(t, 10, l.selectedItemIdx)
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})