on main help sign in
List performance improvements (#1325)

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

commitbe68f48142105f339594e8ca34873a755bf83c6e authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 31 Oct 2025, 16:30 (1 year ago) parentf6e82a4
6 files changed+533−315
internal/tui/exp/list/filterable.go+2−2 internal/tui/exp/list/filterable_group.go+2−3 internal/tui/exp/list/filterable_test.go+1−1 internal/tui/exp/list/grouped.go+5−8 internal/tui/exp/list/list.go+491−269 internal/tui/exp/list/list_test.go+32−32
internal/tui/exp/list/filterable.go+2−2
@@ -102,7 +102,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
102102 f.list = New(items, f.listOptions...).(*list[T])
103103
104104 f.updateKeyMaps()
105− f.items = slices.Collect(f.list.items.Seq())
105+ f.items = f.list.items
106106
107107 if f.inputHidden {
108108 return f
@@ -243,7 +243,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
243243 }
244244 }
245245
246− f.selectedItem = ""
246+ f.selectedItemIdx = -1
247247 if query == "" || len(f.items) == 0 {
248248 return f.list.SetItems(f.items)
249249 }
internal/tui/exp/list/filterable_group.go+2−3
@@ -2,7 +2,6 @@ package list
22
33import (
44 "regexp"
5− "slices"
65 "sort"
76 "strings"
87
@@ -183,7 +182,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
183182
184183func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
185184 var cmds []tea.Cmd
186− for _, item := range slices.Collect(f.items.Seq()) {
185+ for _, item := range f.items {
187186 if i, ok := any(item).(layout.Focusable); ok {
188187 cmds = append(cmds, i.Blur())
189188 }
@@ -253,7 +252,7 @@ func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string
253252
254253func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
255254 cmds := f.clearItemState()
256− f.selectedItem = ""
255+ f.selectedItemIdx = -1
257256
258257 if query == "" {
259258 return f.groupedList.SetGroups(f.groups)
internal/tui/exp/list/filterable_test.go+1−1
@@ -29,7 +29,7 @@ func TestFilterableList(t *testing.T) {
2929 cmd()
3030 }
3131
32− assert.Equal(t, items[0].ID(), l.selectedItem)
32+ assert.Equal(t, 0, l.selectedItemIdx)
3333 golden.RequireEqual(t, []byte(l.View()))
3434 })
3535}
internal/tui/exp/list/grouped.go+5−8
@@ -1,10 +1,7 @@
11package list
22
33import (
4− "slices"
5−
64 tea "github.com/charmbracelet/bubbletea/v2"
7− "github.com/charmbracelet/crush/internal/csync"
85 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
96 "github.com/charmbracelet/crush/internal/tui/util"
107)
@@ -40,9 +37,9 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
4037 keyMap: DefaultKeyMap(),
4138 focused: true,
4239 },
43− items: csync.NewSlice[Item](),
44− indexMap: csync.NewMap[string, int](),
45− renderedItems: csync.NewMap[string, renderedItem](),
40+ items: []Item{},
41+ indexMap: make(map[string]int),
42+ renderedItems: make(map[string]renderedItem),
4643 }
4744 for _, opt := range opts {
4845 opt(list.confOptions)
@@ -85,13 +82,13 @@ func (g *groupedList[T]) convertItems() {
8582 items = append(items, g)
8683 }
8784 }
88− g.items.SetSlice(items)
85+ g.items = items
8986}
9087
9188func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
9289 g.groups = groups
9390 g.convertItems()
94− return g.SetItems(slices.Collect(g.items.Seq()))
91+ return g.SetItems(g.items)
9592}
9693
9794func (g *groupedList[T]) Groups() []Group[T] {
internal/tui/exp/list/list.go+491−269
@@ -1,13 +1,11 @@
11package list
22
33import (
4− "slices"
54 "strings"
65 "sync"
76
87 "github.com/charmbracelet/bubbles/v2/key"
98 tea "github.com/charmbracelet/bubbletea/v2"
10− "github.com/charmbracelet/crush/internal/csync"
119 "github.com/charmbracelet/crush/internal/tui/components/anim"
1210 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
1311 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -19,6 +17,25 @@ import (
1917 "github.com/rivo/uniseg"
2018)
2119
20+const maxGapSize = 100
21+
22+var newlineBuffer = strings.Repeat("\n", maxGapSize)
23+
24+var (
25+ specialCharsMap map[string]struct{}
26+ specialCharsOnce sync.Once
27+)
28+
29+func getSpecialCharsMap() map[string]struct{} {
30+ specialCharsOnce.Do(func() {
31+ specialCharsMap = make(map[string]struct{}, len(styles.SelectionIgnoreIcons))
32+ for _, icon := range styles.SelectionIgnoreIcons {
33+ specialCharsMap[icon] = struct{}{}
34+ }
35+ })
36+ return specialCharsMap
37+}
38+
2239type Item interface {
2340 util.Model
2441 layout.Sizeable
@@ -35,7 +52,6 @@ type List[T Item] interface {
3552 layout.Sizeable
3653 layout.Focusable
3754
38− // Just change state
3955 MoveUp(int) tea.Cmd
4056 MoveDown(int) tea.Cmd
4157 GoToTop() tea.Cmd
@@ -69,11 +85,10 @@ const (
6985
7086const (
7187 ItemNotFound = -1
72− ViewportDefaultScrollSize = 2
88+ ViewportDefaultScrollSize = 5
7389)
7490
7591type renderedItem struct {
76− id string
7792 view string
7893 height int
7994 start int
@@ -81,16 +96,16 @@ type renderedItem struct {
8196}
8297
8398type confOptions struct {
84− width, height int
85− gap int
86− // if you are at the last item and go down it will wrap to the top
87− wrap bool
88− keyMap KeyMap
89− direction direction
90− selectedItem string
91− focused bool
92− resize bool
93− enableMouse bool
99+ width, height int
100+ gap int
101+ wrap bool
102+ keyMap KeyMap
103+ direction direction
104+ selectedItemIdx int // Index of selected item (-1 if none)
105+ selectedItemID string // Temporary storage for WithSelectedItem (resolved in New())
106+ focused bool
107+ resize bool
108+ enableMouse bool
94109}
95110
96111type list[T Item] struct {
@@ -98,19 +113,24 @@ type list[T Item] struct {
98113
99114 offset int
100115
101− indexMap *csync.Map[string, int]
102− items *csync.Slice[T]
116+ indexMap map[string]int
117+ items []T
118+ renderedItems map[string]renderedItem
103119
104− renderedItems *csync.Map[string, renderedItem]
120+ rendered string
121+ renderedHeight int // cached height of rendered content
122+ lineOffsets []int // cached byte offsets for each line (for fast slicing)
105123
106− renderMu sync.Mutex
107− rendered string
124+ cachedView string
125+ cachedViewOffset int
126+ cachedViewDirty bool
108127
109− movingByItem bool
110− selectionStartCol int
111− selectionStartLine int
112− selectionEndCol int
113− selectionEndLine int
128+ movingByItem bool
129+ prevSelectedItemIdx int // Index of previously selected item (-1 if none)
130+ selectionStartCol int
131+ selectionStartLine int
132+ selectionEndCol int
133+ selectionEndLine int
114134
115135 selectionActive bool
116136}
@@ -149,7 +169,7 @@ func WithDirectionBackward() ListOption {
149169// WithSelectedItem sets the initially selected item in the list.
150170func WithSelectedItem(id string) ListOption {
151171 return func(l *confOptions) {
152− l.selectedItem = id
172+ l.selectedItemID = id // Will be resolved to index in New()
153173 }
154174}
155175
@@ -186,17 +206,19 @@ func WithEnableMouse() ListOption {
186206func New[T Item](items []T, opts ...ListOption) List[T] {
187207 list := &list[T]{
188208 confOptions: &confOptions{
189− direction: DirectionForward,
190− keyMap: DefaultKeyMap(),
191− focused: true,
209+ direction: DirectionForward,
210+ keyMap: DefaultKeyMap(),
211+ focused: true,
212+ selectedItemIdx: -1,
192213 },
193− items: csync.NewSliceFrom(items),
194− indexMap: csync.NewMap[string, int](),
195− renderedItems: csync.NewMap[string, renderedItem](),
196− selectionStartCol: -1,
197− selectionStartLine: -1,
198− selectionEndLine: -1,
199− selectionEndCol: -1,
214+ items: items,
215+ indexMap: make(map[string]int, len(items)),
216+ renderedItems: make(map[string]renderedItem),
217+ prevSelectedItemIdx: -1,
218+ selectionStartCol: -1,
219+ selectionStartLine: -1,
220+ selectionEndLine: -1,
221+ selectionEndCol: -1,
200222 }
201223 for _, opt := range opts {
202224 opt(list.confOptions)
@@ -206,8 +228,17 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
206228 if i, ok := any(item).(Indexable); ok {
207229 i.SetIndex(inx)
208230 }
209− list.indexMap.Set(item.ID(), inx)
231+ list.indexMap[item.ID()] = inx
232+ }
233+
234+ // Resolve selectedItemID to selectedItemIdx if specified
235+ if list.selectedItemID != "" {
236+ if idx, ok := list.indexMap[list.selectedItemID]; ok {
237+ list.selectedItemIdx = idx
238+ }
239+ list.selectedItemID = "" // Clear temporary storage
210240 }
241+
211242 return list
212243}
213244
@@ -225,10 +256,25 @@ func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
225256 }
226257 return l, nil
227258 case anim.StepMsg:
259+ // Fast path: if no items, skip processing
260+ if len(l.items) == 0 {
261+ return l, nil
262+ }
263+
264+ // Fast path: check if ANY items are actually spinning before processing
265+ if !l.hasSpinningItems() {
266+ return l, nil
267+ }
268+
228269 var cmds []tea.Cmd
229− for _, item := range slices.Collect(l.items.Seq()) {
230− if i, ok := any(item).(HasAnim); ok && i.Spinning() {
231− updated, cmd := i.Update(msg)
270+ itemsLen := len(l.items)
271+ for i := range itemsLen {
272+ if i >= len(l.items) {
273+ continue
274+ }
275+ item := l.items[i]
276+ if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
277+ updated, cmd := animItem.Update(msg)
232278 cmds = append(cmds, cmd)
233279 if u, ok := updated.(T); ok {
234280 cmds = append(cmds, l.UpdateItem(u.ID(), u))
@@ -288,8 +334,16 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd)
288334 return l, cmd
289335}
290336
291−// selectionView renders the highlighted selection in the view and returns it
292−// as a string. If textOnly is true, it won't render any styles.
337+func (l *list[T]) hasSpinningItems() bool {
338+ for i := range l.items {
339+ item := l.items[i]
340+ if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
341+ return true
342+ }
343+ }
344+ return false
345+}
346+
293347func (l *list[T]) selectionView(view string, textOnly bool) string {
294348 t := styles.CurrentTheme()
295349 area := uv.Rect(0, 0, l.width, l.height)
@@ -302,10 +356,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
302356 }
303357 selArea = selArea.Canon()
304358
305− specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
306− for _, icon := range styles.SelectionIgnoreIcons {
307− specialChars[icon] = true
308− }
359+ specialChars := getSpecialCharsMap()
309360
310361 isNonWhitespace := func(r rune) bool {
311362 return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
@@ -366,7 +417,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
366417 }
367418
368419 char := rune(cellStr[0])
369− isSpecial := specialChars[cellStr]
420+ _, isSpecial := specialChars[cellStr]
370421
371422 if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
372423 if bounds.start == -1 {
@@ -409,7 +460,10 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
409460 }
410461
411462 cellStr := cell.String()
412− if len(cellStr) > 0 && !specialChars[cellStr] {
463+ if len(cellStr) > 0 {
464+ if _, isSpecial := specialChars[cellStr]; isSpecial {
465+ continue
466+ }
413467 if textOnly {
414468 // Collect selected text without styles
415469 selectedText.WriteString(cell.String())
@@ -439,33 +493,40 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
439493 return scr.Render()
440494}
441495
442−// View implements List.
443496func (l *list[T]) View() string {
444497 if l.height <= 0 || l.width <= 0 {
445498 return ""
446499 }
500+
501+ if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
502+ return l.cachedView
503+ }
504+
447505 t := styles.CurrentTheme()
448− view := l.rendered
449− lines := strings.Split(view, "\n")
450506
451507 start, end := l.viewPosition()
452508 viewStart := max(0, start)
453− viewEnd := min(len(lines), end+1)
509+ viewEnd := end
454510
455511 if viewStart > viewEnd {
456− viewStart = viewEnd
512+ return ""
457513 }
458− lines = lines[viewStart:viewEnd]
514+
515+ view := l.getLines(viewStart, viewEnd)
459516
460517 if l.resize {
461− return strings.Join(lines, "\n")
518+ return view
462519 }
520+
463521 view = t.S().Base.
464522 Height(l.height).
465523 Width(l.width).
466− Render(strings.Join(lines, "\n"))
524+ Render(view)
467525
468526 if !l.hasSelection() {
527+ l.cachedView = view
528+ l.cachedViewOffset = l.offset
529+ l.cachedViewDirty = false
469530 return view
470531 }
471532
@@ -474,7 +535,7 @@ func (l *list[T]) View() string {
474535
475536func (l *list[T]) viewPosition() (int, int) {
476537 start, end := 0, 0
477− renderedLines := lipgloss.Height(l.rendered) - 1
538+ renderedLines := l.renderedHeight - 1
478539 if l.direction == DirectionForward {
479540 start = max(0, l.offset)
480541 end = min(l.offset+l.height-1, renderedLines)
@@ -486,22 +547,114 @@ func (l *list[T]) viewPosition() (int, int) {
486547 return start, end
487548}
488549
550+func (l *list[T]) setRendered(rendered string) {
551+ l.rendered = rendered
552+ l.renderedHeight = lipgloss.Height(rendered)
553+ l.cachedViewDirty = true // Mark view cache as dirty
554+
555+ if len(rendered) > 0 {
556+ l.lineOffsets = make([]int, 0, l.renderedHeight)
557+ l.lineOffsets = append(l.lineOffsets, 0)
558+
559+ offset := 0
560+ for {
561+ idx := strings.IndexByte(rendered[offset:], '\n')
562+ if idx == -1 {
563+ break
564+ }
565+ offset += idx + 1
566+ l.lineOffsets = append(l.lineOffsets, offset)
567+ }
568+ } else {
569+ l.lineOffsets = nil
570+ }
571+}
572+
573+func (l *list[T]) getLines(start, end int) string {
574+ if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
575+ return ""
576+ }
577+
578+ if end >= len(l.lineOffsets) {
579+ end = len(l.lineOffsets) - 1
580+ }
581+ if start > end {
582+ return ""
583+ }
584+
585+ startOffset := l.lineOffsets[start]
586+ var endOffset int
587+ if end+1 < len(l.lineOffsets) {
588+ endOffset = l.lineOffsets[end+1] - 1
589+ } else {
590+ endOffset = len(l.rendered)
591+ }
592+
593+ if startOffset >= len(l.rendered) {
594+ return ""
595+ }
596+ endOffset = min(endOffset, len(l.rendered))
597+
598+ return l.rendered[startOffset:endOffset]
599+}
600+
601+// getLine returns a single line from the rendered content using lineOffsets.
602+// This avoids allocating a new string for each line like strings.Split does.
603+func (l *list[T]) getLine(index int) string {
604+ if len(l.lineOffsets) == 0 || index < 0 || index >= len(l.lineOffsets) {
605+ return ""
606+ }
607+
608+ startOffset := l.lineOffsets[index]
609+ var endOffset int
610+ if index+1 < len(l.lineOffsets) {
611+ endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
612+ } else {
613+ endOffset = len(l.rendered)
614+ }
615+
616+ if startOffset >= len(l.rendered) {
617+ return ""
618+ }
619+ endOffset = min(endOffset, len(l.rendered))
620+
621+ return l.rendered[startOffset:endOffset]
622+}
623+
624+// lineCount returns the number of lines in the rendered content.
625+func (l *list[T]) lineCount() int {
626+ return len(l.lineOffsets)
627+}
628+
489629func (l *list[T]) recalculateItemPositions() {
490− currentContentHeight := 0
491− for _, item := range slices.Collect(l.items.Seq()) {
492− rItem, ok := l.renderedItems.Get(item.ID())
630+ l.recalculateItemPositionsFrom(0)
631+}
632+
633+func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
634+ var currentContentHeight int
635+
636+ if startIdx > 0 && startIdx <= len(l.items) {
637+ prevItem := l.items[startIdx-1]
638+ if rItem, ok := l.renderedItems[prevItem.ID()]; ok {
639+ currentContentHeight = rItem.end + 1 + l.gap
640+ }
641+ }
642+
643+ for i := startIdx; i < len(l.items); i++ {
644+ item := l.items[i]
645+ rItem, ok := l.renderedItems[item.ID()]
493646 if !ok {
494647 continue
495648 }
496649 rItem.start = currentContentHeight
497650 rItem.end = currentContentHeight + rItem.height - 1
498− l.renderedItems.Set(item.ID(), rItem)
651+ l.renderedItems[item.ID()] = rItem
499652 currentContentHeight = rItem.end + 1 + l.gap
500653 }
501654}
502655
503656func (l *list[T]) render() tea.Cmd {
504− if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
657+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
505658 return nil
506659 }
507660 l.setDefaultSelected()
@@ -512,51 +665,38 @@ func (l *list[T]) render() tea.Cmd {
512665 } else {
513666 focusChangeCmd = l.blurSelectedItem()
514667 }
515− // we are not rendering the first time
516668 if l.rendered != "" {
517− // rerender everything will mostly hit cache
518− l.renderMu.Lock()
519− l.rendered, _ = l.renderIterator(0, false, "")
520− l.renderMu.Unlock()
669+ rendered, _ := l.renderIterator(0, false, "")
670+ l.setRendered(rendered)
521671 if l.direction == DirectionBackward {
522672 l.recalculateItemPositions()
523673 }
524− // in the end scroll to the selected item
525674 if l.focused {
526675 l.scrollToSelection()
527676 }
528677 return focusChangeCmd
529678 }
530− l.renderMu.Lock()
531679 rendered, finishIndex := l.renderIterator(0, true, "")
532− l.rendered = rendered
533− l.renderMu.Unlock()
534− // recalculate for the initial items
680+ l.setRendered(rendered)
535681 if l.direction == DirectionBackward {
536682 l.recalculateItemPositions()
537683 }
538− renderCmd := func() tea.Msg {
539− l.offset = 0
540− // render the rest
541684
542− l.renderMu.Lock()
543− l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
544− l.renderMu.Unlock()
545− // needed for backwards
546− if l.direction == DirectionBackward {
547− l.recalculateItemPositions()
548− }
549− // in the end scroll to the selected item
550− if l.focused {
551− l.scrollToSelection()
552− }
553− return nil
685+ l.offset = 0
686+ rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
687+ l.setRendered(rendered)
688+ if l.direction == DirectionBackward {
689+ l.recalculateItemPositions()
554690 }
555− return tea.Batch(focusChangeCmd, renderCmd)
691+ if l.focused {
692+ l.scrollToSelection()
693+ }
694+
695+ return focusChangeCmd
556696}
557697
558698func (l *list[T]) setDefaultSelected() {
559− if l.selectedItem == "" {
699+ if l.selectedItemIdx < 0 {
560700 if l.direction == DirectionForward {
561701 l.selectFirstItem()
562702 } else {
@@ -566,27 +706,29 @@ func (l *list[T]) setDefaultSelected() {
566706}
567707
568708func (l *list[T]) scrollToSelection() {
569− rItem, ok := l.renderedItems.Get(l.selectedItem)
709+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
710+ l.selectedItemIdx = -1
711+ l.setDefaultSelected()
712+ return
713+ }
714+ item := l.items[l.selectedItemIdx]
715+ rItem, ok := l.renderedItems[item.ID()]
570716 if !ok {
571− l.selectedItem = ""
717+ l.selectedItemIdx = -1
572718 l.setDefaultSelected()
573719 return
574720 }
575721
576722 start, end := l.viewPosition()
577− // item bigger or equal to the viewport do nothing
578723 if rItem.start <= start && rItem.end >= end {
579724 return
580725 }
581− // if we are moving by item we want to move the offset so that the
582− // whole item is visible not just portions of it
583726 if l.movingByItem {
584727 if rItem.start >= start && rItem.end <= end {
585728 return
586729 }
587730 defer func() { l.movingByItem = false }()
588731 } else {
589− // item already in view do nothing
590732 if rItem.start >= start && rItem.start <= end {
591733 return
592734 }
@@ -599,14 +741,13 @@ func (l *list[T]) scrollToSelection() {
599741 if l.direction == DirectionForward {
600742 l.offset = rItem.start
601743 } else {
602− l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
744+ l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
603745 }
604746 return
605747 }
606748
607− renderedLines := lipgloss.Height(l.rendered) - 1
749+ renderedLines := l.renderedHeight - 1
608750
609− // If item is above the viewport, make it the first item
610751 if rItem.start < start {
611752 if l.direction == DirectionForward {
612753 l.offset = rItem.start
@@ -614,7 +755,6 @@ func (l *list[T]) scrollToSelection() {
614755 l.offset = max(0, renderedLines-rItem.start-l.height+1)
615756 }
616757 } else if rItem.end > end {
617− // If item is below the viewport, make it the last item
618758 if l.direction == DirectionForward {
619759 l.offset = max(0, rItem.end-l.height+1)
620760 } else {
@@ -624,7 +764,11 @@ func (l *list[T]) scrollToSelection() {
624764}
625765
626766func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
627− rItem, ok := l.renderedItems.Get(l.selectedItem)
767+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
768+ return nil
769+ }
770+ item := l.items[l.selectedItemIdx]
771+ rItem, ok := l.renderedItems[item.ID()]
628772 if !ok {
629773 return nil
630774 }
@@ -643,64 +787,60 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
643787 if itemMiddle < start {
644788 // select the first item in the viewport
645789 // the item is most likely an item coming after this item
646− inx, ok := l.indexMap.Get(rItem.id)
647− if !ok {
648− return nil
649− }
790+ inx := l.selectedItemIdx
650791 for {
651792 inx = l.firstSelectableItemBelow(inx)
652793 if inx == ItemNotFound {
653794 return nil
654795 }
655− item, ok := l.items.Get(inx)
656− if !ok {
796+ if inx < 0 || inx >= len(l.items) {
657797 continue
658798 }
659− renderedItem, ok := l.renderedItems.Get(item.ID())
799+
800+ item := l.items[inx]
801+ renderedItem, ok := l.renderedItems[item.ID()]
660802 if !ok {
661803 continue
662804 }
663805
664806 // If the item is bigger than the viewport, select it
665807 if renderedItem.start <= start && renderedItem.end >= end {
666− l.selectedItem = renderedItem.id
808+ l.selectedItemIdx = inx
667809 return l.render()
668810 }
669811 // item is in the view
670812 if renderedItem.start >= start && renderedItem.start <= end {
671− l.selectedItem = renderedItem.id
813+ l.selectedItemIdx = inx
672814 return l.render()
673815 }
674816 }
675817 } else if itemMiddle > end {
676818 // select the first item in the viewport
677819 // the item is most likely an item coming after this item
678− inx, ok := l.indexMap.Get(rItem.id)
679− if !ok {
680− return nil
681− }
820+ inx := l.selectedItemIdx
682821 for {
683822 inx = l.firstSelectableItemAbove(inx)
684823 if inx == ItemNotFound {
685824 return nil
686825 }
687− item, ok := l.items.Get(inx)
688− if !ok {
826+ if inx < 0 || inx >= len(l.items) {
689827 continue
690828 }
691− renderedItem, ok := l.renderedItems.Get(item.ID())
829+
830+ item := l.items[inx]
831+ renderedItem, ok := l.renderedItems[item.ID()]
692832 if !ok {
693833 continue
694834 }
695835
696836 // If the item is bigger than the viewport, select it
697837 if renderedItem.start <= start && renderedItem.end >= end {
698− l.selectedItem = renderedItem.id
838+ l.selectedItemIdx = inx
699839 return l.render()
700840 }
701841 // item is in the view
702842 if renderedItem.end >= start && renderedItem.end <= end {
703− l.selectedItem = renderedItem.id
843+ l.selectedItemIdx = inx
704844 return l.render()
705845 }
706846 }
@@ -711,46 +851,42 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
711851func (l *list[T]) selectFirstItem() {
712852 inx := l.firstSelectableItemBelow(-1)
713853 if inx != ItemNotFound {
714− item, ok := l.items.Get(inx)
715− if ok {
716− l.selectedItem = item.ID()
717− }
854+ l.selectedItemIdx = inx
718855 }
719856}
720857
721858func (l *list[T]) selectLastItem() {
722− inx := l.firstSelectableItemAbove(l.items.Len())
859+ inx := l.firstSelectableItemAbove(len(l.items))
723860 if inx != ItemNotFound {
724− item, ok := l.items.Get(inx)
725− if ok {
726− l.selectedItem = item.ID()
727− }
861+ l.selectedItemIdx = inx
728862 }
729863}
730864
731865func (l *list[T]) firstSelectableItemAbove(inx int) int {
732866 for i := inx - 1; i >= 0; i-- {
733− item, ok := l.items.Get(i)
734− if !ok {
867+ if i < 0 || i >= len(l.items) {
735868 continue
736869 }
870+
871+ item := l.items[i]
737872 if _, ok := any(item).(layout.Focusable); ok {
738873 return i
739874 }
740875 }
741876 if inx == 0 && l.wrap {
742− return l.firstSelectableItemAbove(l.items.Len())
877+ return l.firstSelectableItemAbove(len(l.items))
743878 }
744879 return ItemNotFound
745880}
746881
747882func (l *list[T]) firstSelectableItemBelow(inx int) int {
748− itemsLen := l.items.Len()
883+ itemsLen := len(l.items)
749884 for i := inx + 1; i < itemsLen; i++ {
750− item, ok := l.items.Get(i)
751− if !ok {
885+ if i < 0 || i >= len(l.items) {
752886 continue
753887 }
888+
889+ item := l.items[i]
754890 if _, ok := any(item).(layout.Focusable); ok {
755891 return i
756892 }
@@ -762,38 +898,52 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
762898}
763899
764900func (l *list[T]) focusSelectedItem() tea.Cmd {
765− if l.selectedItem == "" || !l.focused {
901+ if l.selectedItemIdx < 0 || !l.focused {
766902 return nil
767903 }
768− var cmds []tea.Cmd
769− for _, item := range slices.Collect(l.items.Seq()) {
770− if f, ok := any(item).(layout.Focusable); ok {
771− if item.ID() == l.selectedItem && !f.IsFocused() {
772− cmds = append(cmds, f.Focus())
773− l.renderedItems.Del(item.ID())
774− } else if item.ID() != l.selectedItem && f.IsFocused() {
775− cmds = append(cmds, f.Blur())
776− l.renderedItems.Del(item.ID())
777− }
904+ // Pre-allocate with expected capacity
905+ cmds := make([]tea.Cmd, 0, 2)
906+
907+ // Blur the previously selected item if it's different
908+ if l.prevSelectedItemIdx >= 0 && l.prevSelectedItemIdx != l.selectedItemIdx && l.prevSelectedItemIdx < len(l.items) {
909+ prevItem := l.items[l.prevSelectedItemIdx]
910+ if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
911+ cmds = append(cmds, f.Blur())
912+ // Mark cache as needing update, but don't delete yet
913+ // This allows the render to potentially reuse it
914+ delete(l.renderedItems, prevItem.ID())
778915 }
779916 }
917+
918+ // Focus the currently selected item
919+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
920+ item := l.items[l.selectedItemIdx]
921+ if f, ok := any(item).(layout.Focusable); ok && !f.IsFocused() {
922+ cmds = append(cmds, f.Focus())
923+ // Mark for re-render
924+ delete(l.renderedItems, item.ID())
925+ }
926+ }
927+
928+ l.prevSelectedItemIdx = l.selectedItemIdx
780929 return tea.Batch(cmds...)
781930}
782931
783932func (l *list[T]) blurSelectedItem() tea.Cmd {
784− if l.selectedItem == "" || l.focused {
933+ if l.selectedItemIdx < 0 || l.focused {
785934 return nil
786935 }
787− var cmds []tea.Cmd
788− for _, item := range slices.Collect(l.items.Seq()) {
789− if f, ok := any(item).(layout.Focusable); ok {
790− if item.ID() == l.selectedItem && f.IsFocused() {
791− cmds = append(cmds, f.Blur())
792− l.renderedItems.Del(item.ID())
793− }
936+
937+ // Blur the currently selected item
938+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
939+ item := l.items[l.selectedItemIdx]
940+ if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
941+ delete(l.renderedItems, item.ID())
942+ return f.Blur()
794943 }
795944 }
796− return tea.Batch(cmds...)
945+
946+ return nil
797947}
798948
799949// renderFragment holds updated rendered view fragments
@@ -806,10 +956,15 @@ type renderFragment struct {
806956// returns the last index and the rendered content so far
807957// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
808958func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
809− var fragments []renderFragment
959+ // Pre-allocate fragments with expected capacity
960+ itemsLen := len(l.items)
961+ expectedFragments := itemsLen - startInx
962+ if limitHeight && l.height > 0 {
963+ expectedFragments = min(expectedFragments, l.height)
964+ }
965+ fragments := make([]renderFragment, 0, expectedFragments)
810966
811967 currentContentHeight := lipgloss.Height(rendered) - 1
812− itemsLen := l.items.Len()
813968 finalIndex := itemsLen
814969
815970 // 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
826981 inx = (itemsLen - 1) - i
827982 }
828983
829− item, ok := l.items.Get(inx)
830− if !ok {
984+ if inx < 0 || inx >= len(l.items) {
831985 continue
832986 }
833987
988+ item := l.items[inx]
989+
834990 var rItem renderedItem
835− if cache, ok := l.renderedItems.Get(item.ID()); ok {
991+ if cache, ok := l.renderedItems[item.ID()]; ok {
836992 rItem = cache
837993 } else {
838994 rItem = l.renderItem(item)
839995 rItem.start = currentContentHeight
840996 rItem.end = currentContentHeight + rItem.height - 1
841− l.renderedItems.Set(item.ID(), rItem)
997+ l.renderedItems[item.ID()] = rItem
842998 }
843999
8441000 gap := l.gap + 1
@@ -853,12 +1009,26 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
8531009
8541010 // second pass: build rendered string efficiently
8551011 var b strings.Builder
1012+
1013+ // Pre-size the builder to reduce allocations
1014+ estimatedSize := len(rendered)
1015+ for _, f := range fragments {
1016+ estimatedSize += len(f.view) + f.gap
1017+ }
1018+ b.Grow(estimatedSize)
1019+
8561020 if l.direction == DirectionForward {
8571021 b.WriteString(rendered)
858− for _, f := range fragments {
1022+ for i := range fragments {
1023+ f := &fragments[i]
8591024 b.WriteString(f.view)
860− for range f.gap {
861− b.WriteByte('\n')
1025+ // Optimized gap writing using pre-allocated buffer
1026+ if f.gap > 0 {
1027+ if f.gap <= maxGapSize {
1028+ b.WriteString(newlineBuffer[:f.gap])
1029+ } else {
1030+ b.WriteString(strings.Repeat("\n", f.gap))
1031+ }
8621032 }
8631033 }
8641034
@@ -867,10 +1037,15 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
8671037
8681038 // iterate backwards as fragments are in reversed order
8691039 for i := len(fragments) - 1; i >= 0; i-- {
870− f := fragments[i]
1040+ f := &fragments[i]
8711041 b.WriteString(f.view)
872− for range f.gap {
873− b.WriteByte('\n')
1042+ // Optimized gap writing using pre-allocated buffer
1043+ if f.gap > 0 {
1044+ if f.gap <= maxGapSize {
1045+ b.WriteString(newlineBuffer[:f.gap])
1046+ } else {
1047+ b.WriteString(strings.Repeat("\n", f.gap))
1048+ }
8741049 }
8751050 }
8761051 b.WriteString(rendered)
@@ -881,7 +1056,6 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
8811056func (l *list[T]) renderItem(item Item) renderedItem {
8821057 view := item.View()
8831058 return renderedItem{
884− id: item.ID(),
8851059 view: view,
8861060 height: lipgloss.Height(view),
8871061 }
@@ -889,17 +1063,17 @@ func (l *list[T]) renderItem(item Item) renderedItem {
8891063
8901064// AppendItem implements List.
8911065func (l *list[T]) AppendItem(item T) tea.Cmd {
892− var cmds []tea.Cmd
1066+ // Pre-allocate with expected capacity
1067+ cmds := make([]tea.Cmd, 0, 4)
8931068 cmd := item.Init()
8941069 if cmd != nil {
8951070 cmds = append(cmds, cmd)
8961071 }
8971072
898− l.items.Append(item)
899− l.indexMap = csync.NewMap[string, int]()
900− for inx, item := range slices.Collect(l.items.Seq()) {
901− l.indexMap.Set(item.ID(), inx)
902− }
1073+ newIndex := len(l.items)
1074+ l.items = append(l.items, item)
1075+ l.indexMap[item.ID()] = newIndex
1076+
9031077 if l.width > 0 && l.height > 0 {
9041078 cmd = item.SetSize(l.width, l.height)
9051079 if cmd != nil {
@@ -917,13 +1091,13 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
9171091 cmds = append(cmds, cmd)
9181092 }
9191093 } else {
920− newItem, ok := l.renderedItems.Get(item.ID())
1094+ newItem, ok := l.renderedItems[item.ID()]
9211095 if ok {
9221096 newLines := newItem.height
923− if l.items.Len() > 1 {
1097+ if len(l.items) > 1 {
9241098 newLines += l.gap
9251099 }
926− l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1100+ l.offset = min(l.renderedHeight-1, l.offset+newLines)
9271101 }
9281102 }
9291103 }
@@ -938,35 +1112,41 @@ func (l *list[T]) Blur() tea.Cmd {
9381112
9391113// DeleteItem implements List.
9401114func (l *list[T]) DeleteItem(id string) tea.Cmd {
941− inx, ok := l.indexMap.Get(id)
1115+ inx, ok := l.indexMap[id]
9421116 if !ok {
9431117 return nil
9441118 }
945− l.items.Delete(inx)
946− l.renderedItems.Del(id)
947− for inx, item := range slices.Collect(l.items.Seq()) {
948− l.indexMap.Set(item.ID(), inx)
1119+ l.items = append(l.items[:inx], l.items[inx+1:]...)
1120+ delete(l.renderedItems, id)
1121+ delete(l.indexMap, id)
1122+
1123+ // Only update indices for items after the deleted one
1124+ itemsLen := len(l.items)
1125+ for i := inx; i < itemsLen; i++ {
1126+ if i >= 0 && i < len(l.items) {
1127+ item := l.items[i]
1128+ l.indexMap[item.ID()] = i
1129+ }
9491130 }
9501131
951− if l.selectedItem == id {
1132+ // Adjust selectedItemIdx if the deleted item was selected or before it
1133+ if l.selectedItemIdx == inx {
1134+ // Deleted item was selected, select the previous item if possible
9521135 if inx > 0 {
953− item, ok := l.items.Get(inx - 1)
954− if ok {
955− l.selectedItem = item.ID()
956− } else {
957− l.selectedItem = ""
958− }
1136+ l.selectedItemIdx = inx - 1
9591137 } else {
960− l.selectedItem = ""
1138+ l.selectedItemIdx = -1
9611139 }
1140+ } else if l.selectedItemIdx > inx {
1141+ // Selected item is after the deleted one, shift index down
1142+ l.selectedItemIdx--
9621143 }
9631144 cmd := l.render()
9641145 if l.rendered != "" {
965− renderedHeight := lipgloss.Height(l.rendered)
966− if renderedHeight <= l.height {
1146+ if l.renderedHeight <= l.height {
9671147 l.offset = 0
9681148 } else {
969− maxOffset := renderedHeight - l.height
1149+ maxOffset := l.renderedHeight - l.height
9701150 if l.offset > maxOffset {
9711151 l.offset = maxOffset
9721152 }
@@ -989,7 +1169,7 @@ func (l *list[T]) GetSize() (int, int) {
9891169// GoToBottom implements List.
9901170func (l *list[T]) GoToBottom() tea.Cmd {
9911171 l.offset = 0
992− l.selectedItem = ""
1172+ l.selectedItemIdx = -1
9931173 l.direction = DirectionBackward
9941174 return l.render()
9951175}
@@ -997,7 +1177,7 @@ func (l *list[T]) GoToBottom() tea.Cmd {
9971177// GoToTop implements List.
9981178func (l *list[T]) GoToTop() tea.Cmd {
9991179 l.offset = 0
1000− l.selectedItem = ""
1180+ l.selectedItemIdx = -1
10011181 l.direction = DirectionForward
10021182 return l.render()
10031183}
@@ -1009,21 +1189,29 @@ func (l *list[T]) IsFocused() bool {
10091189
10101190// Items implements List.
10111191func (l *list[T]) Items() []T {
1012− return slices.Collect(l.items.Seq())
1192+ itemsLen := len(l.items)
1193+ result := make([]T, 0, itemsLen)
1194+ for i := range itemsLen {
1195+ if i >= 0 && i < len(l.items) {
1196+ item := l.items[i]
1197+ result = append(result, item)
1198+ }
1199+ }
1200+ return result
10131201}
10141202
10151203func (l *list[T]) incrementOffset(n int) {
1016− renderedHeight := lipgloss.Height(l.rendered)
10171204 // no need for offset
1018− if renderedHeight <= l.height {
1205+ if l.renderedHeight <= l.height {
10191206 return
10201207 }
1021− maxOffset := renderedHeight - l.height
1208+ maxOffset := l.renderedHeight - l.height
10221209 n = min(n, maxOffset-l.offset)
10231210 if n <= 0 {
10241211 return
10251212 }
10261213 l.offset += n
1214+ l.cachedViewDirty = true
10271215}
10281216
10291217func (l *list[T]) decrementOffset(n int) {
@@ -1035,6 +1223,7 @@ func (l *list[T]) decrementOffset(n int) {
10351223 if l.offset < 0 {
10361224 l.offset = 0
10371225 }
1226+ l.cachedViewDirty = true
10381227}
10391228
10401229// MoveDown implements List.
@@ -1105,14 +1294,26 @@ func (l *list[T]) MoveUp(n int) tea.Cmd {
11051294
11061295// PrependItem implements List.
11071296func (l *list[T]) PrependItem(item T) tea.Cmd {
1108− cmds := []tea.Cmd{
1109− item.Init(),
1297+ // Pre-allocate with expected capacity
1298+ cmds := make([]tea.Cmd, 0, 4)
1299+ cmds = append(cmds, item.Init())
1300+
1301+ l.items = append([]T{item}, l.items...)
1302+
1303+ // Shift selectedItemIdx since all items moved down by 1
1304+ if l.selectedItemIdx >= 0 {
1305+ l.selectedItemIdx++
11101306 }
1111− l.items.Prepend(item)
1112− l.indexMap = csync.NewMap[string, int]()
1113− for inx, item := range slices.Collect(l.items.Seq()) {
1114− l.indexMap.Set(item.ID(), inx)
1307+
1308+ // Update index map incrementally: shift all existing indices up by 1
1309+ // This is more efficient than rebuilding from scratch
1310+ newIndexMap := make(map[string]int, len(l.indexMap)+1)
1311+ for id, idx := range l.indexMap {
1312+ newIndexMap[id] = idx + 1 // All existing items shift down by 1
11151313 }
1314+ newIndexMap[item.ID()] = 0 // New item is at index 0
1315+ l.indexMap = newIndexMap
1316+
11161317 if l.width > 0 && l.height > 0 {
11171318 cmds = append(cmds, item.SetSize(l.width, l.height))
11181319 }
@@ -1124,13 +1325,13 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
11241325 cmds = append(cmds, cmd)
11251326 }
11261327 } else {
1127− newItem, ok := l.renderedItems.Get(item.ID())
1328+ newItem, ok := l.renderedItems[item.ID()]
11281329 if ok {
11291330 newLines := newItem.height
1130− if l.items.Len() > 1 {
1331+ if len(l.items) > 1 {
11311332 newLines += l.gap
11321333 }
1133− l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1334+ l.offset = min(l.renderedHeight-1, l.offset+newLines)
11341335 }
11351336 }
11361337 }
@@ -1139,17 +1340,17 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
11391340
11401341// SelectItemAbove implements List.
11411342func (l *list[T]) SelectItemAbove() tea.Cmd {
1142− inx, ok := l.indexMap.Get(l.selectedItem)
1143− if !ok {
1343+ if l.selectedItemIdx < 0 {
11441344 return nil
11451345 }
11461346
1147− newIndex := l.firstSelectableItemAbove(inx)
1347+ newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
11481348 if newIndex == ItemNotFound {
11491349 // no item above
11501350 return nil
11511351 }
1152− var cmds []tea.Cmd
1352+ // Pre-allocate with expected capacity
1353+ cmds := make([]tea.Cmd, 0, 2)
11531354 if newIndex == 1 {
11541355 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
11551356 if peakAboveIndex == ItemNotFound {
@@ -1160,11 +1361,11 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
11601361 }
11611362 }
11621363 }
1163− item, ok := l.items.Get(newIndex)
1164− if !ok {
1364+ if newIndex < 0 || newIndex >= len(l.items) {
11651365 return nil
11661366 }
1167− l.selectedItem = item.ID()
1367+ l.prevSelectedItemIdx = l.selectedItemIdx
1368+ l.selectedItemIdx = newIndex
11681369 l.movingByItem = true
11691370 renderCmd := l.render()
11701371 if renderCmd != nil {
@@ -1175,46 +1376,38 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
11751376
11761377// SelectItemBelow implements List.
11771378func (l *list[T]) SelectItemBelow() tea.Cmd {
1178− inx, ok := l.indexMap.Get(l.selectedItem)
1179− if !ok {
1379+ if l.selectedItemIdx < 0 {
11801380 return nil
11811381 }
11821382
1183− newIndex := l.firstSelectableItemBelow(inx)
1383+ newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
11841384 if newIndex == ItemNotFound {
11851385 // no item above
11861386 return nil
11871387 }
1188− item, ok := l.items.Get(newIndex)
1189− if !ok {
1388+ if newIndex < 0 || newIndex >= len(l.items) {
11901389 return nil
11911390 }
1192− l.selectedItem = item.ID()
1391+ l.prevSelectedItemIdx = l.selectedItemIdx
1392+ l.selectedItemIdx = newIndex
11931393 l.movingByItem = true
11941394 return l.render()
11951395}
11961396
11971397// SelectedItem implements List.
11981398func (l *list[T]) SelectedItem() *T {
1199− inx, ok := l.indexMap.Get(l.selectedItem)
1200− if !ok {
1201− return nil
1202− }
1203− if inx > l.items.Len()-1 {
1204− return nil
1205− }
1206− item, ok := l.items.Get(inx)
1207− if !ok {
1399+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
12081400 return nil
12091401 }
1402+ item := l.items[l.selectedItemIdx]
12101403 return &item
12111404}
12121405
12131406// SetItems implements List.
12141407func (l *list[T]) SetItems(items []T) tea.Cmd {
1215− l.items.SetSlice(items)
1408+ l.items = items
12161409 var cmds []tea.Cmd
1217− for inx, item := range slices.Collect(l.items.Seq()) {
1410+ for inx, item := range items {
12181411 if i, ok := any(item).(Indexable); ok {
12191412 i.SetIndex(inx)
12201413 }
@@ -1226,23 +1419,44 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
12261419
12271420// SetSelected implements List.
12281421func (l *list[T]) SetSelected(id string) tea.Cmd {
1229− l.selectedItem = id
1422+ l.prevSelectedItemIdx = l.selectedItemIdx
1423+ if idx, ok := l.indexMap[id]; ok {
1424+ l.selectedItemIdx = idx
1425+ } else {
1426+ l.selectedItemIdx = -1
1427+ }
12301428 return l.render()
12311429}
12321430
1233−func (l *list[T]) reset(selectedItem string) tea.Cmd {
1431+func (l *list[T]) reset(selectedItemID string) tea.Cmd {
12341432 var cmds []tea.Cmd
12351433 l.rendered = ""
1434+ l.renderedHeight = 0
12361435 l.offset = 0
1237− l.selectedItem = selectedItem
1238− l.indexMap = csync.NewMap[string, int]()
1239− l.renderedItems = csync.NewMap[string, renderedItem]()
1240− for inx, item := range slices.Collect(l.items.Seq()) {
1241− l.indexMap.Set(item.ID(), inx)
1436+ l.indexMap = make(map[string]int)
1437+ l.renderedItems = make(map[string]renderedItem)
1438+ itemsLen := len(l.items)
1439+ for i := range itemsLen {
1440+ if i < 0 || i >= len(l.items) {
1441+ continue
1442+ }
1443+
1444+ item := l.items[i]
1445+ l.indexMap[item.ID()] = i
12421446 if l.width > 0 && l.height > 0 {
12431447 cmds = append(cmds, item.SetSize(l.width, l.height))
12441448 }
12451449 }
1450+ // Convert selectedItemID to index after rebuilding indexMap
1451+ if selectedItemID != "" {
1452+ if idx, ok := l.indexMap[selectedItemID]; ok {
1453+ l.selectedItemIdx = idx
1454+ } else {
1455+ l.selectedItemIdx = -1
1456+ }
1457+ } else {
1458+ l.selectedItemIdx = -1
1459+ }
12461460 cmds = append(cmds, l.render())
12471461 return tea.Batch(cmds...)
12481462}
@@ -1253,7 +1467,13 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
12531467 l.width = width
12541468 l.height = height
12551469 if oldWidth != width {
1256− cmd := l.reset(l.selectedItem)
1470+ // Get current selected item ID before reset
1471+ selectedID := ""
1472+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
1473+ item := l.items[l.selectedItemIdx]
1474+ selectedID = item.ID()
1475+ }
1476+ cmd := l.reset(selectedID)
12571477 return cmd
12581478 }
12591479 return nil
@@ -1261,16 +1481,17 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
12611481
12621482// UpdateItem implements List.
12631483func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1264− var cmds []tea.Cmd
1265− if inx, ok := l.indexMap.Get(id); ok {
1266− l.items.Set(inx, item)
1267− oldItem, hasOldItem := l.renderedItems.Get(id)
1484+ // Pre-allocate with expected capacity
1485+ cmds := make([]tea.Cmd, 0, 1)
1486+ if inx, ok := l.indexMap[id]; ok {
1487+ l.items[inx] = item
1488+ oldItem, hasOldItem := l.renderedItems[id]
12681489 oldPosition := l.offset
12691490 if l.direction == DirectionBackward {
1270− oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
1491+ oldPosition = (l.renderedHeight - 1) - l.offset
12711492 }
12721493
1273− l.renderedItems.Del(id)
1494+ delete(l.renderedItems, id)
12741495 cmd := l.render()
12751496
12761497 // need to check for nil because of sequence not handling nil
@@ -1281,17 +1502,17 @@ func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
12811502 // if we are the last item and there is no offset
12821503 // make sure to go to the bottom
12831504 if oldPosition < oldItem.end {
1284− newItem, ok := l.renderedItems.Get(item.ID())
1505+ newItem, ok := l.renderedItems[item.ID()]
12851506 if ok {
12861507 newLines := newItem.height - oldItem.height
1287− l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1508+ l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
12881509 }
12891510 }
12901511 } else if hasOldItem && l.offset > oldItem.start {
1291− newItem, ok := l.renderedItems.Get(item.ID())
1512+ newItem, ok := l.renderedItems[item.ID()]
12921513 if ok {
12931514 newLines := newItem.height - oldItem.height
1294− l.offset = ordered.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1515+ l.offset = ordered.Clamp(l.offset+newLines, 0, l.renderedHeight-1)
12951516 }
12961517 }
12971518 }
@@ -1333,13 +1554,10 @@ func (l *list[T]) SelectionClear() {
13331554}
13341555
13351556func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1336− lines := strings.Split(l.rendered, "\n")
1337− for i, l := range lines {
1338− lines[i] = ansi.Strip(l)
1339− }
1557+ numLines := l.lineCount()
13401558
1341− if l.direction == DirectionBackward && len(lines) > l.height {
1342− line = ((len(lines) - 1) - l.height) + line + 1
1559+ if l.direction == DirectionBackward && numLines > l.height {
1560+ line = ((numLines - 1) - l.height) + line + 1
13431561 }
13441562
13451563 if l.offset > 0 {
@@ -1350,11 +1568,11 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
13501568 }
13511569 }
13521570
1353− if line < 0 || line >= len(lines) {
1571+ if line < 0 || line >= numLines {
13541572 return 0, 0
13551573 }
13561574
1357− currentLine := lines[line]
1575+ currentLine := ansi.Strip(l.getLine(line))
13581576 gr := uniseg.NewGraphemes(currentLine)
13591577 startCol = -1
13601578 upTo := col
@@ -1377,15 +1595,19 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
13771595}
13781596
13791597func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1380− lines := strings.Split(l.rendered, "\n")
1381− for i, l := range lines {
1382− lines[i] = ansi.Strip(l)
1598+ // Helper function to get a line with ANSI stripped and icons replaced
1599+ getCleanLine := func(index int) string {
1600+ rawLine := l.getLine(index)
1601+ cleanLine := ansi.Strip(rawLine)
13831602 for _, icon := range styles.SelectionIgnoreIcons {
1384− lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1603+ cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
13851604 }
1605+ return cleanLine
13861606 }
1387− if l.direction == DirectionBackward && len(lines) > l.height {
1388− line = (len(lines) - 1) - l.height + line + 1
1607+
1608+ numLines := l.lineCount()
1609+ if l.direction == DirectionBackward && numLines > l.height {
1610+ line = (numLines - 1) - l.height + line + 1
13891611 }
13901612
13911613 if l.offset > 0 {
@@ -1397,30 +1619,30 @@ func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, fou
13971619 }
13981620
13991621 // Ensure line is within bounds
1400− if line < 0 || line >= len(lines) {
1622+ if line < 0 || line >= numLines {
14011623 return 0, 0, false
14021624 }
14031625
1404− if strings.TrimSpace(lines[line]) == "" {
1626+ if strings.TrimSpace(getCleanLine(line)) == "" {
14051627 return 0, 0, false
14061628 }
14071629
14081630 // Find start of paragraph (search backwards for empty line or start of text)
14091631 startLine = line
1410− for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1632+ for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
14111633 startLine--
14121634 }
14131635
14141636 // Find end of paragraph (search forwards for empty line or end of text)
14151637 endLine = line
1416− for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1638+ for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
14171639 endLine++
14181640 }
14191641
14201642 // revert the line numbers if we are in backward direction
1421− if l.direction == DirectionBackward && len(lines) > l.height {
1422− startLine = startLine - (len(lines) - 1) + l.height - 1
1423− endLine = endLine - (len(lines) - 1) + l.height - 1
1643+ if l.direction == DirectionBackward && numLines > l.height {
1644+ startLine = startLine - (numLines - 1) + l.height - 1
1645+ endLine = endLine - (numLines - 1) + l.height - 1
14241646 }
14251647 if l.offset > 0 {
14261648 if l.direction == DirectionBackward {
internal/tui/exp/list/list_test.go+32−32
@@ -28,18 +28,18 @@ func TestList(t *testing.T) {
2828 execCmd(l, l.Init())
2929
3030 // should select the last item
31− assert.Equal(t, items[0].ID(), l.selectedItem)
31+ assert.Equal(t, 0, l.selectedItemIdx)
3232 assert.Equal(t, 0, l.offset)
33− require.Equal(t, 5, l.indexMap.Len())
34− require.Equal(t, 5, l.items.Len())
35− require.Equal(t, 5, l.renderedItems.Len())
33+ require.Equal(t, 5, len(l.indexMap))
34+ require.Equal(t, 5, len(l.items))
35+ require.Equal(t, 5, len(l.renderedItems))
3636 assert.Equal(t, 5, lipgloss.Height(l.rendered))
3737 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
3838 start, end := l.viewPosition()
3939 assert.Equal(t, 0, start)
4040 assert.Equal(t, 4, end)
4141 for i := range 5 {
42− item, ok := l.renderedItems.Get(items[i].ID())
42+ item, ok := l.renderedItems[items[i].ID()]
4343 require.True(t, ok)
4444 assert.Equal(t, i, item.start)
4545 assert.Equal(t, i, item.end)
@@ -58,18 +58,18 @@ func TestList(t *testing.T) {
5858 execCmd(l, l.Init())
5959
6060 // should select the last item
61− assert.Equal(t, items[4].ID(), l.selectedItem)
61+ assert.Equal(t, 4, l.selectedItemIdx)
6262 assert.Equal(t, 0, l.offset)
63− require.Equal(t, 5, l.indexMap.Len())
64− require.Equal(t, 5, l.items.Len())
65− require.Equal(t, 5, l.renderedItems.Len())
63+ require.Equal(t, 5, len(l.indexMap))
64+ require.Equal(t, 5, len(l.items))
65+ require.Equal(t, 5, len(l.renderedItems))
6666 assert.Equal(t, 5, lipgloss.Height(l.rendered))
6767 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
6868 start, end := l.viewPosition()
6969 assert.Equal(t, 0, start)
7070 assert.Equal(t, 4, end)
7171 for i := range 5 {
72− item, ok := l.renderedItems.Get(items[i].ID())
72+ item, ok := l.renderedItems[items[i].ID()]
7373 require.True(t, ok)
7474 assert.Equal(t, i, item.start)
7575 assert.Equal(t, i, item.end)
@@ -89,18 +89,18 @@ func TestList(t *testing.T) {
8989 execCmd(l, l.Init())
9090
9191 // should select the last item
92− assert.Equal(t, items[0].ID(), l.selectedItem)
92+ assert.Equal(t, 0, l.selectedItemIdx)
9393 assert.Equal(t, 0, l.offset)
94− require.Equal(t, 30, l.indexMap.Len())
95− require.Equal(t, 30, l.items.Len())
96− require.Equal(t, 30, l.renderedItems.Len())
94+ require.Equal(t, 30, len(l.indexMap))
95+ require.Equal(t, 30, len(l.items))
96+ require.Equal(t, 30, len(l.renderedItems))
9797 assert.Equal(t, 30, lipgloss.Height(l.rendered))
9898 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
9999 start, end := l.viewPosition()
100100 assert.Equal(t, 0, start)
101101 assert.Equal(t, 9, end)
102102 for i := range 30 {
103− item, ok := l.renderedItems.Get(items[i].ID())
103+ item, ok := l.renderedItems[items[i].ID()]
104104 require.True(t, ok)
105105 assert.Equal(t, i, item.start)
106106 assert.Equal(t, i, item.end)
@@ -119,18 +119,18 @@ func TestList(t *testing.T) {
119119 execCmd(l, l.Init())
120120
121121 // should select the last item
122− assert.Equal(t, items[29].ID(), l.selectedItem)
122+ assert.Equal(t, 29, l.selectedItemIdx)
123123 assert.Equal(t, 0, l.offset)
124− require.Equal(t, 30, l.indexMap.Len())
125− require.Equal(t, 30, l.items.Len())
126− require.Equal(t, 30, l.renderedItems.Len())
124+ require.Equal(t, 30, len(l.indexMap))
125+ require.Equal(t, 30, len(l.items))
126+ require.Equal(t, 30, len(l.renderedItems))
127127 assert.Equal(t, 30, lipgloss.Height(l.rendered))
128128 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
129129 start, end := l.viewPosition()
130130 assert.Equal(t, 20, start)
131131 assert.Equal(t, 29, end)
132132 for i := range 30 {
133− item, ok := l.renderedItems.Get(items[i].ID())
133+ item, ok := l.renderedItems[items[i].ID()]
134134 require.True(t, ok)
135135 assert.Equal(t, i, item.start)
136136 assert.Equal(t, i, item.end)
@@ -152,11 +152,11 @@ func TestList(t *testing.T) {
152152 execCmd(l, l.Init())
153153
154154 // should select the last item
155− assert.Equal(t, items[0].ID(), l.selectedItem)
155+ assert.Equal(t, 0, l.selectedItemIdx)
156156 assert.Equal(t, 0, l.offset)
157− require.Equal(t, 30, l.indexMap.Len())
158− require.Equal(t, 30, l.items.Len())
159− require.Equal(t, 30, l.renderedItems.Len())
157+ require.Equal(t, 30, len(l.indexMap))
158+ require.Equal(t, 30, len(l.items))
159+ require.Equal(t, 30, len(l.renderedItems))
160160 expectedLines := 0
161161 for i := range 30 {
162162 expectedLines += (i + 1) * 1
@@ -168,7 +168,7 @@ func TestList(t *testing.T) {
168168 assert.Equal(t, 9, end)
169169 currentPosition := 0
170170 for i := range 30 {
171− rItem, ok := l.renderedItems.Get(items[i].ID())
171+ rItem, ok := l.renderedItems[items[i].ID()]
172172 require.True(t, ok)
173173 assert.Equal(t, currentPosition, rItem.start)
174174 assert.Equal(t, currentPosition+i, rItem.end)
@@ -190,11 +190,11 @@ func TestList(t *testing.T) {
190190 execCmd(l, l.Init())
191191
192192 // should select the last item
193− assert.Equal(t, items[29].ID(), l.selectedItem)
193+ assert.Equal(t, 29, l.selectedItemIdx)
194194 assert.Equal(t, 0, l.offset)
195− require.Equal(t, 30, l.indexMap.Len())
196− require.Equal(t, 30, l.items.Len())
197− require.Equal(t, 30, l.renderedItems.Len())
195+ require.Equal(t, 30, len(l.indexMap))
196+ require.Equal(t, 30, len(l.items))
197+ require.Equal(t, 30, len(l.renderedItems))
198198 expectedLines := 0
199199 for i := range 30 {
200200 expectedLines += (i + 1) * 1
@@ -206,7 +206,7 @@ func TestList(t *testing.T) {
206206 assert.Equal(t, expectedLines-1, end)
207207 currentPosition := 0
208208 for i := range 30 {
209− rItem, ok := l.renderedItems.Get(items[i].ID())
209+ rItem, ok := l.renderedItems[items[i].ID()]
210210 require.True(t, ok)
211211 assert.Equal(t, currentPosition, rItem.start)
212212 assert.Equal(t, currentPosition+i, rItem.end)
@@ -229,7 +229,7 @@ func TestList(t *testing.T) {
229229 execCmd(l, l.Init())
230230
231231 // should select the last item
232− assert.Equal(t, items[10].ID(), l.selectedItem)
232+ assert.Equal(t, 10, l.selectedItemIdx)
233233
234234 golden.RequireEqual(t, []byte(l.View()))
235235 })
@@ -247,7 +247,7 @@ func TestList(t *testing.T) {
247247 execCmd(l, l.Init())
248248
249249 // should select the last item
250− assert.Equal(t, items[10].ID(), l.selectedItem)
250+ assert.Equal(t, 10, l.selectedItemIdx)
251251
252252 golden.RequireEqual(t, []byte(l.View()))
253253 })