@@ -1,13 +1,11 @@
1package list
1package list
8 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
9 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
10− "github.com/charmbracelet/crush/internal/csync"
11 "github.com/charmbracelet/crush/internal/tui/components/anim"
9 "github.com/charmbracelet/crush/internal/tui/components/anim"
12 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
10 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
13 "github.com/charmbracelet/crush/internal/tui/styles"
11 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -19,6 +17,25 @@ import (
19 "github.com/rivo/uniseg"
17 "github.com/rivo/uniseg"
20+const maxGapSize = 100
22+var newlineBuffer = strings.Repeat("\n", maxGapSize)
25+ specialCharsMap map[string]struct{}
26+ specialCharsOnce sync.Once
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{}{}
36+ return specialCharsMap
22type Item interface {
39type Item interface {
23 util.Model
40 util.Model
24 layout.Sizeable
41 layout.Sizeable
@@ -35,7 +52,6 @@ type List[T Item] interface {
35 layout.Sizeable
52 layout.Sizeable
36 layout.Focusable
53 layout.Focusable
39 MoveUp(int) tea.Cmd
55 MoveUp(int) tea.Cmd
40 MoveDown(int) tea.Cmd
56 MoveDown(int) tea.Cmd
41 GoToTop() tea.Cmd
57 GoToTop() tea.Cmd
@@ -69,11 +85,10 @@ const (
71 ItemNotFound = -1
87 ItemNotFound = -1
72− ViewportDefaultScrollSize = 2
88+ ViewportDefaultScrollSize = 5
75type renderedItem struct {
91type renderedItem struct {
77 view string
92 view string
78 height int
93 height int
@@ -81,16 +96,16 @@ type renderedItem struct {
83type confOptions struct {
98type confOptions struct {
84− width, height int
99+ width, height int
86− // if you are at the last item and go down it will wrap to the top
87− wrap bool
101+ wrap bool
88− keyMap KeyMap
102+ keyMap KeyMap
89− direction direction
103+ direction direction
90− selectedItem string
104+ selectedItemIdx int // Index of selected item (-1 if none)
105+ selectedItemID string // Temporary storage for WithSelectedItem (resolved in New())
91− focused bool
106+ focused bool
92− resize bool
107+ resize bool
93− enableMouse bool
108+ enableMouse bool
96type list[T Item] struct {
111type list[T Item] struct {
@@ -98,19 +113,24 @@ type list[T Item] struct {
99 offset int
114 offset int
101− indexMap *csync.Map[string, int]
116+ indexMap map[string]int
102− items *csync.Slice[T]
117+ items []T
118+ renderedItems map[string]renderedItem
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)
107− rendered string
124+ cachedView string
125+ cachedViewOffset int
126+ cachedViewDirty bool
109− movingByItem bool
128+ movingByItem bool
129+ prevSelectedItemIdx int // Index of previously selected item (-1 if none)
110− selectionStartCol int
130+ selectionStartCol int
111− selectionStartLine int
131+ selectionStartLine int
112− selectionEndCol int
132+ selectionEndCol int
113− selectionEndLine int
133+ selectionEndLine int
115 selectionActive bool
135 selectionActive bool
@@ -149,7 +169,7 @@ func WithDirectionBackward() ListOption {
149// WithSelectedItem sets the initially selected item in the list.
169// WithSelectedItem sets the initially selected item in the list.
150func WithSelectedItem(id string) ListOption {
170func WithSelectedItem(id string) ListOption {
151 return func(l *confOptions) {
171 return func(l *confOptions) {
152− l.selectedItem = id
172+ l.selectedItemID = id // Will be resolved to index in New()
@@ -186,17 +206,19 @@ func WithEnableMouse() ListOption {
186func New[T Item](items []T, opts ...ListOption) List[T] {
206func New[T Item](items []T, opts ...ListOption) List[T] {
187 list := &list[T]{
207 list := &list[T]{
188 confOptions: &confOptions{
208 confOptions: &confOptions{
189− direction: DirectionForward,
209+ direction: DirectionForward,
190− keyMap: DefaultKeyMap(),
210+ keyMap: DefaultKeyMap(),
191− focused: true,
211+ focused: true,
212+ selectedItemIdx: -1,
193− items: csync.NewSliceFrom(items),
214+ items: items,
194− indexMap: csync.NewMap[string, int](),
215+ indexMap: make(map[string]int, len(items)),
195− renderedItems: csync.NewMap[string, renderedItem](),
216+ renderedItems: make(map[string]renderedItem),
217+ prevSelectedItemIdx: -1,
196− selectionStartCol: -1,
218+ selectionStartCol: -1,
197− selectionStartLine: -1,
219+ selectionStartLine: -1,
198− selectionEndLine: -1,
220+ selectionEndLine: -1,
199− selectionEndCol: -1,
221+ selectionEndCol: -1,
201 for _, opt := range opts {
223 for _, opt := range opts {
202 opt(list.confOptions)
224 opt(list.confOptions)
@@ -206,8 +228,17 @@ func New[T Item](items []T, opts ...ListOption) List[T] {
206 if i, ok := any(item).(Indexable); ok {
228 if i, ok := any(item).(Indexable); ok {
207 i.SetIndex(inx)
229 i.SetIndex(inx)
209− list.indexMap.Set(item.ID(), inx)
231+ list.indexMap[item.ID()] = inx
234+ // Resolve selectedItemID to selectedItemIdx if specified
235+ if list.selectedItemID != "" {
236+ if idx, ok := list.indexMap[list.selectedItemID]; ok {
237+ list.selectedItemIdx = idx
239+ list.selectedItemID = "" // Clear temporary storage
211 return list
242 return list
@@ -225,10 +256,25 @@ func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
226 return l, nil
257 return l, nil
227 case anim.StepMsg:
258 case anim.StepMsg:
259+ // Fast path: if no items, skip processing
260+ if len(l.items) == 0 {
264+ // Fast path: check if ANY items are actually spinning before processing
265+ if !l.hasSpinningItems() {
228 var cmds []tea.Cmd
269 var cmds []tea.Cmd
229− for _, item := range slices.Collect(l.items.Seq()) {
270+ itemsLen := len(l.items)
271+ for i := range itemsLen {
272+ if i >= len(l.items) {
230− if i, ok := any(item).(HasAnim); ok && i.Spinning() {
276+ if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
231− updated, cmd := i.Update(msg)
277+ updated, cmd := animItem.Update(msg)
232 cmds = append(cmds, cmd)
278 cmds = append(cmds, cmd)
233 if u, ok := updated.(T); ok {
279 if u, ok := updated.(T); ok {
234 cmds = append(cmds, l.UpdateItem(u.ID(), u))
280 cmds = append(cmds, l.UpdateItem(u.ID(), u))
@@ -288,8 +334,16 @@ func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (util.Model, tea.Cmd)
288 return l, cmd
334 return l, cmd
291−// selectionView renders the highlighted selection in the view and returns it
337+func (l *list[T]) hasSpinningItems() bool {
292−// as a string. If textOnly is true, it won't render any styles.
338+ for i := range l.items {
340+ if animItem, ok := any(item).(HasAnim); ok && animItem.Spinning() {
293func (l *list[T]) selectionView(view string, textOnly bool) string {
347func (l *list[T]) selectionView(view string, textOnly bool) string {
294 t := styles.CurrentTheme()
348 t := styles.CurrentTheme()
295 area := uv.Rect(0, 0, l.width, l.height)
349 area := uv.Rect(0, 0, l.width, l.height)
@@ -302,10 +356,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
303 selArea = selArea.Canon()
357 selArea = selArea.Canon()
305− specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
359+ specialChars := getSpecialCharsMap()
306− for _, icon := range styles.SelectionIgnoreIcons {
307− specialChars[icon] = true
310 isNonWhitespace := func(r rune) bool {
361 isNonWhitespace := func(r rune) bool {
311 return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
362 return r != ' ' && r != '\t' && r != 0 && r != '\n' && r != '\r'
@@ -366,7 +417,7 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
368 char := rune(cellStr[0])
419 char := rune(cellStr[0])
369− isSpecial := specialChars[cellStr]
420+ _, isSpecial := specialChars[cellStr]
371 if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
422 if (isNonWhitespace(char) && !isSpecial) || cell.Style.Bg != nil {
372 if bounds.start == -1 {
423 if bounds.start == -1 {
@@ -409,7 +460,10 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
411 cellStr := cell.String()
462 cellStr := cell.String()
412− if len(cellStr) > 0 && !specialChars[cellStr] {
463+ if len(cellStr) > 0 {
464+ if _, isSpecial := specialChars[cellStr]; isSpecial {
413 if textOnly {
467 if textOnly {
414 // Collect selected text without styles
468 // Collect selected text without styles
415 selectedText.WriteString(cell.String())
469 selectedText.WriteString(cell.String())
@@ -439,33 +493,40 @@ func (l *list[T]) selectionView(view string, textOnly bool) string {
439 return scr.Render()
493 return scr.Render()
442−// View implements List.
443func (l *list[T]) View() string {
496func (l *list[T]) View() string {
444 if l.height <= 0 || l.width <= 0 {
497 if l.height <= 0 || l.width <= 0 {
445 return ""
498 return ""
501+ if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
447 t := styles.CurrentTheme()
505 t := styles.CurrentTheme()
449− lines := strings.Split(view, "\n")
451 start, end := l.viewPosition()
507 start, end := l.viewPosition()
452 viewStart := max(0, start)
508 viewStart := max(0, start)
453− viewEnd := min(len(lines), end+1)
509+ viewEnd := end
455 if viewStart > viewEnd {
511 if viewStart > viewEnd {
456− viewStart = viewEnd
512+ return ""
458− lines = lines[viewStart:viewEnd]
515+ view := l.getLines(viewStart, viewEnd)
460 if l.resize {
517 if l.resize {
461− return strings.Join(lines, "\n")
518+ return view
463 view = t.S().Base.
521 view = t.S().Base.
464 Height(l.height).
522 Height(l.height).
465 Width(l.width).
523 Width(l.width).
466− Render(strings.Join(lines, "\n"))
524+ Render(view)
468 if !l.hasSelection() {
526 if !l.hasSelection() {
528+ l.cachedViewOffset = l.offset
529+ l.cachedViewDirty = false
469 return view
530 return view
@@ -474,7 +535,7 @@ func (l *list[T]) View() string {
475func (l *list[T]) viewPosition() (int, int) {
536func (l *list[T]) viewPosition() (int, int) {
476 start, end := 0, 0
537 start, end := 0, 0
477− renderedLines := lipgloss.Height(l.rendered) - 1
538+ renderedLines := l.renderedHeight - 1
478 if l.direction == DirectionForward {
539 if l.direction == DirectionForward {
479 start = max(0, l.offset)
540 start = max(0, l.offset)
480 end = min(l.offset+l.height-1, renderedLines)
541 end = min(l.offset+l.height-1, renderedLines)
@@ -486,22 +547,114 @@ func (l *list[T]) viewPosition() (int, int) {
486 return start, end
547 return start, end
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
555+ if len(rendered) > 0 {
556+ l.lineOffsets = make([]int, 0, l.renderedHeight)
557+ l.lineOffsets = append(l.lineOffsets, 0)
561+ idx := strings.IndexByte(rendered[offset:], '\n')
566+ l.lineOffsets = append(l.lineOffsets, offset)
573+func (l *list[T]) getLines(start, end int) string {
574+ if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
578+ if end >= len(l.lineOffsets) {
579+ end = len(l.lineOffsets) - 1
585+ startOffset := l.lineOffsets[start]
587+ if end+1 < len(l.lineOffsets) {
588+ endOffset = l.lineOffsets[end+1] - 1
590+ endOffset = len(l.rendered)
593+ if startOffset >= len(l.rendered) {
596+ endOffset = min(endOffset, len(l.rendered))
598+ return l.rendered[startOffset:endOffset]
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) {
608+ startOffset := l.lineOffsets[index]
610+ if index+1 < len(l.lineOffsets) {
611+ endOffset = l.lineOffsets[index+1] - 1 // -1 to exclude the newline
613+ endOffset = len(l.rendered)
616+ if startOffset >= len(l.rendered) {
619+ endOffset = min(endOffset, len(l.rendered))
621+ return l.rendered[startOffset:endOffset]
624+// lineCount returns the number of lines in the rendered content.
625+func (l *list[T]) lineCount() int {
626+ return len(l.lineOffsets)
489func (l *list[T]) recalculateItemPositions() {
629func (l *list[T]) recalculateItemPositions() {
630+ l.recalculateItemPositionsFrom(0)
633+func (l *list[T]) recalculateItemPositionsFrom(startIdx int) {
490− currentContentHeight := 0
634+ var currentContentHeight int
491− for _, item := range slices.Collect(l.items.Seq()) {
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
643+ for i := startIdx; i < len(l.items); i++ {
492− rItem, ok := l.renderedItems.Get(item.ID())
645+ rItem, ok := l.renderedItems[item.ID()]
496 rItem.start = currentContentHeight
649 rItem.start = currentContentHeight
497 rItem.end = currentContentHeight + rItem.height - 1
650 rItem.end = currentContentHeight + rItem.height - 1
498− l.renderedItems.Set(item.ID(), rItem)
651+ l.renderedItems[item.ID()] = rItem
499 currentContentHeight = rItem.end + 1 + l.gap
652 currentContentHeight = rItem.end + 1 + l.gap
503func (l *list[T]) render() tea.Cmd {
656func (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 {
505 return nil
658 return nil
507 l.setDefaultSelected()
660 l.setDefaultSelected()
@@ -512,51 +665,38 @@ func (l *list[T]) render() tea.Cmd {
513 focusChangeCmd = l.blurSelectedItem()
666 focusChangeCmd = l.blurSelectedItem()
515− // we are not rendering the first time
516 if l.rendered != "" {
668 if l.rendered != "" {
517− // rerender everything will mostly hit cache
519− l.rendered, _ = l.renderIterator(0, false, "")
669+ rendered, _ := l.renderIterator(0, false, "")
520− l.renderMu.Unlock()
670+ l.setRendered(rendered)
521 if l.direction == DirectionBackward {
671 if l.direction == DirectionBackward {
522 l.recalculateItemPositions()
672 l.recalculateItemPositions()
524− // in the end scroll to the selected item
525 if l.focused {
674 if l.focused {
526 l.scrollToSelection()
675 l.scrollToSelection()
528 return focusChangeCmd
677 return focusChangeCmd
531 rendered, finishIndex := l.renderIterator(0, true, "")
679 rendered, finishIndex := l.renderIterator(0, true, "")
532− l.rendered = rendered
680+ l.setRendered(rendered)
534− // recalculate for the initial items
535 if l.direction == DirectionBackward {
681 if l.direction == DirectionBackward {
536 l.recalculateItemPositions()
682 l.recalculateItemPositions()
538− renderCmd := func() tea.Msg {
542− l.renderMu.Lock()
685+ l.offset = 0
543− l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
686+ rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
544− l.renderMu.Unlock()
687+ l.setRendered(rendered)
545− // needed for backwards
546− if l.direction == DirectionBackward {
688+ if l.direction == DirectionBackward {
547− l.recalculateItemPositions()
689+ l.recalculateItemPositions()
549− // in the end scroll to the selected item
551− l.scrollToSelection()
692+ l.scrollToSelection()
555− return tea.Batch(focusChangeCmd, renderCmd)
695+ return focusChangeCmd
558func (l *list[T]) setDefaultSelected() {
698func (l *list[T]) setDefaultSelected() {
559− if l.selectedItem == "" {
699+ if l.selectedItemIdx < 0 {
560 if l.direction == DirectionForward {
700 if l.direction == DirectionForward {
561 l.selectFirstItem()
701 l.selectFirstItem()
@@ -566,27 +706,29 @@ func (l *list[T]) setDefaultSelected() {
568func (l *list[T]) scrollToSelection() {
708func (l *list[T]) scrollToSelection() {
709+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
710+ l.selectedItemIdx = -1
711+ l.setDefaultSelected()
714+ item := l.items[l.selectedItemIdx]
569− rItem, ok := l.renderedItems.Get(l.selectedItem)
715+ rItem, ok := l.renderedItems[item.ID()]
571− l.selectedItem = ""
717+ l.selectedItemIdx = -1
572 l.setDefaultSelected()
718 l.setDefaultSelected()
576 start, end := l.viewPosition()
722 start, end := l.viewPosition()
577− // item bigger or equal to the viewport do nothing
578 if rItem.start <= start && rItem.end >= end {
723 if rItem.start <= start && rItem.end >= end {
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
583 if l.movingByItem {
726 if l.movingByItem {
584 if rItem.start >= start && rItem.end <= end {
727 if rItem.start >= start && rItem.end <= end {
587 defer func() { l.movingByItem = false }()
730 defer func() { l.movingByItem = false }()
589− // item already in view do nothing
590 if rItem.start >= start && rItem.start <= end {
732 if rItem.start >= start && rItem.start <= end {
@@ -599,14 +741,13 @@ func (l *list[T]) scrollToSelection() {
599 if l.direction == DirectionForward {
741 if l.direction == DirectionForward {
600 l.offset = rItem.start
742 l.offset = rItem.start
602− l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
744+ l.offset = max(0, l.renderedHeight-(rItem.start+l.height))
607− renderedLines := lipgloss.Height(l.rendered) - 1
749+ renderedLines := l.renderedHeight - 1
609− // If item is above the viewport, make it the first item
610 if rItem.start < start {
751 if rItem.start < start {
611 if l.direction == DirectionForward {
752 if l.direction == DirectionForward {
612 l.offset = rItem.start
753 l.offset = rItem.start
@@ -614,7 +755,6 @@ func (l *list[T]) scrollToSelection() {
614 l.offset = max(0, renderedLines-rItem.start-l.height+1)
755 l.offset = max(0, renderedLines-rItem.start-l.height+1)
616 } else if rItem.end > end {
757 } else if rItem.end > end {
617− // If item is below the viewport, make it the last item
618 if l.direction == DirectionForward {
758 if l.direction == DirectionForward {
619 l.offset = max(0, rItem.end-l.height+1)
759 l.offset = max(0, rItem.end-l.height+1)
@@ -624,7 +764,11 @@ func (l *list[T]) scrollToSelection() {
626func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
766func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
767+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
770+ item := l.items[l.selectedItemIdx]
627− rItem, ok := l.renderedItems.Get(l.selectedItem)
771+ rItem, ok := l.renderedItems[item.ID()]
629 return nil
773 return nil
@@ -643,64 +787,60 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
643 if itemMiddle < start {
787 if itemMiddle < start {
644 // select the first item in the viewport
788 // select the first item in the viewport
645 // the item is most likely an item coming after this item
789 // the item is most likely an item coming after this item
646− inx, ok := l.indexMap.Get(rItem.id)
790+ inx := l.selectedItemIdx
651 inx = l.firstSelectableItemBelow(inx)
792 inx = l.firstSelectableItemBelow(inx)
652 if inx == ItemNotFound {
793 if inx == ItemNotFound {
653 return nil
794 return nil
655− item, ok := l.items.Get(inx)
796+ if inx < 0 || inx >= len(l.items) {
800+ item := l.items[inx]
659− renderedItem, ok := l.renderedItems.Get(item.ID())
801+ renderedItem, ok := l.renderedItems[item.ID()]
664 // If the item is bigger than the viewport, select it
806 // If the item is bigger than the viewport, select it
665 if renderedItem.start <= start && renderedItem.end >= end {
807 if renderedItem.start <= start && renderedItem.end >= end {
666− l.selectedItem = renderedItem.id
808+ l.selectedItemIdx = inx
667 return l.render()
809 return l.render()
669 // item is in the view
811 // item is in the view
670 if renderedItem.start >= start && renderedItem.start <= end {
812 if renderedItem.start >= start && renderedItem.start <= end {
671− l.selectedItem = renderedItem.id
813+ l.selectedItemIdx = inx
672 return l.render()
814 return l.render()
675 } else if itemMiddle > end {
817 } else if itemMiddle > end {
676 // select the first item in the viewport
818 // select the first item in the viewport
677 // the item is most likely an item coming after this item
819 // the item is most likely an item coming after this item
678− inx, ok := l.indexMap.Get(rItem.id)
820+ inx := l.selectedItemIdx
683 inx = l.firstSelectableItemAbove(inx)
822 inx = l.firstSelectableItemAbove(inx)
684 if inx == ItemNotFound {
823 if inx == ItemNotFound {
685 return nil
824 return nil
687− item, ok := l.items.Get(inx)
826+ if inx < 0 || inx >= len(l.items) {
830+ item := l.items[inx]
691− renderedItem, ok := l.renderedItems.Get(item.ID())
831+ renderedItem, ok := l.renderedItems[item.ID()]
696 // If the item is bigger than the viewport, select it
836 // If the item is bigger than the viewport, select it
697 if renderedItem.start <= start && renderedItem.end >= end {
837 if renderedItem.start <= start && renderedItem.end >= end {
698− l.selectedItem = renderedItem.id
838+ l.selectedItemIdx = inx
699 return l.render()
839 return l.render()
701 // item is in the view
841 // item is in the view
702 if renderedItem.end >= start && renderedItem.end <= end {
842 if renderedItem.end >= start && renderedItem.end <= end {
703− l.selectedItem = renderedItem.id
843+ l.selectedItemIdx = inx
704 return l.render()
844 return l.render()
@@ -711,46 +851,42 @@ func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
711func (l *list[T]) selectFirstItem() {
851func (l *list[T]) selectFirstItem() {
712 inx := l.firstSelectableItemBelow(-1)
852 inx := l.firstSelectableItemBelow(-1)
713 if inx != ItemNotFound {
853 if inx != ItemNotFound {
714− item, ok := l.items.Get(inx)
854+ l.selectedItemIdx = inx
716− l.selectedItem = item.ID()
721func (l *list[T]) selectLastItem() {
858func (l *list[T]) selectLastItem() {
722− inx := l.firstSelectableItemAbove(l.items.Len())
859+ inx := l.firstSelectableItemAbove(len(l.items))
723 if inx != ItemNotFound {
860 if inx != ItemNotFound {
724− item, ok := l.items.Get(inx)
861+ l.selectedItemIdx = inx
726− l.selectedItem = item.ID()
731func (l *list[T]) firstSelectableItemAbove(inx int) int {
865func (l *list[T]) firstSelectableItemAbove(inx int) int {
732 for i := inx - 1; i >= 0; i-- {
866 for i := inx - 1; i >= 0; i-- {
733− item, ok := l.items.Get(i)
867+ if i < 0 || i >= len(l.items) {
737 if _, ok := any(item).(layout.Focusable); ok {
872 if _, ok := any(item).(layout.Focusable); ok {
741 if inx == 0 && l.wrap {
876 if inx == 0 && l.wrap {
742− return l.firstSelectableItemAbove(l.items.Len())
877+ return l.firstSelectableItemAbove(len(l.items))
744 return ItemNotFound
879 return ItemNotFound
747func (l *list[T]) firstSelectableItemBelow(inx int) int {
882func (l *list[T]) firstSelectableItemBelow(inx int) int {
748− itemsLen := l.items.Len()
883+ itemsLen := len(l.items)
749 for i := inx + 1; i < itemsLen; i++ {
884 for i := inx + 1; i < itemsLen; i++ {
750− item, ok := l.items.Get(i)
885+ if i < 0 || i >= len(l.items) {
754 if _, ok := any(item).(layout.Focusable); ok {
890 if _, ok := any(item).(layout.Focusable); ok {
@@ -762,38 +898,52 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
764func (l *list[T]) focusSelectedItem() tea.Cmd {
900func (l *list[T]) focusSelectedItem() tea.Cmd {
765− if l.selectedItem == "" || !l.focused {
901+ if l.selectedItemIdx < 0 || !l.focused {
766 return nil
902 return nil
904+ // Pre-allocate with expected capacity
768− var cmds []tea.Cmd
905+ cmds := make([]tea.Cmd, 0, 2)
769− for _, item := range slices.Collect(l.items.Seq()) {
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]
770− if f, ok := any(item).(layout.Focusable); ok {
910+ if f, ok := any(prevItem).(layout.Focusable); ok && f.IsFocused() {
771− if item.ID() == l.selectedItem && !f.IsFocused() {
772− cmds = append(cmds, f.Focus())
911+ cmds = append(cmds, f.Blur())
773− l.renderedItems.Del(item.ID())
912+ // Mark cache as needing update, but don't delete yet
774− } else if item.ID() != l.selectedItem && f.IsFocused() {
913+ // This allows the render to potentially reuse it
775− cmds = append(cmds, f.Blur())
776− l.renderedItems.Del(item.ID())
914+ delete(l.renderedItems, prevItem.ID())
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())
928+ l.prevSelectedItemIdx = l.selectedItemIdx
780 return tea.Batch(cmds...)
929 return tea.Batch(cmds...)
783func (l *list[T]) blurSelectedItem() tea.Cmd {
932func (l *list[T]) blurSelectedItem() tea.Cmd {
784− if l.selectedItem == "" || l.focused {
933+ if l.selectedItemIdx < 0 || l.focused {
785 return nil
934 return nil
787− var cmds []tea.Cmd
936+
788− for _, item := range slices.Collect(l.items.Seq()) {
937+ // Blur the currently selected item
938+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
939+ item := l.items[l.selectedItemIdx]
789− if f, ok := any(item).(layout.Focusable); ok {
940+ if f, ok := any(item).(layout.Focusable); ok && f.IsFocused() {
790− if item.ID() == l.selectedItem && f.IsFocused() {
791− cmds = append(cmds, f.Blur())
792− l.renderedItems.Del(item.ID())
941+ delete(l.renderedItems, item.ID())
793− }
942+ return f.Blur()
796− return tea.Batch(cmds...)
946+ return nil
799// renderFragment holds updated rendered view fragments
949// renderFragment holds updated rendered view fragments
@@ -806,10 +956,15 @@ type renderFragment struct {
806// returns the last index and the rendered content so far
956// returns the last index and the rendered content so far
807// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
957// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
808func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
958func (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)
965+ fragments := make([]renderFragment, 0, expectedFragments)
811 currentContentHeight := lipgloss.Height(rendered) - 1
967 currentContentHeight := lipgloss.Height(rendered) - 1
812− itemsLen := l.items.Len()
813 finalIndex := itemsLen
968 finalIndex := itemsLen
815 // first pass: accumulate all fragments to render until the height limit is
970 // 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
826 inx = (itemsLen - 1) - i
981 inx = (itemsLen - 1) - i
829− item, ok := l.items.Get(inx)
984+ if inx < 0 || inx >= len(l.items) {
988+ item := l.items[inx]
834 var rItem renderedItem
990 var rItem renderedItem
835− if cache, ok := l.renderedItems.Get(item.ID()); ok {
991+ if cache, ok := l.renderedItems[item.ID()]; ok {
836 rItem = cache
992 rItem = cache
838 rItem = l.renderItem(item)
994 rItem = l.renderItem(item)
839 rItem.start = currentContentHeight
995 rItem.start = currentContentHeight
840 rItem.end = currentContentHeight + rItem.height - 1
996 rItem.end = currentContentHeight + rItem.height - 1
841− l.renderedItems.Set(item.ID(), rItem)
997+ l.renderedItems[item.ID()] = rItem
844 gap := l.gap + 1
1000 gap := l.gap + 1
@@ -853,12 +1009,26 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
854 // second pass: build rendered string efficiently
1010 // second pass: build rendered string efficiently
855 var b strings.Builder
1011 var b strings.Builder
1013+ // Pre-size the builder to reduce allocations
1014+ estimatedSize := len(rendered)
1015+ for _, f := range fragments {
1016+ estimatedSize += len(f.view) + f.gap
1018+ b.Grow(estimatedSize)
856 if l.direction == DirectionForward {
1020 if l.direction == DirectionForward {
857 b.WriteString(rendered)
1021 b.WriteString(rendered)
858− for _, f := range fragments {
1022+ for i := range fragments {
859 b.WriteString(f.view)
1024 b.WriteString(f.view)
1025+ // Optimized gap writing using pre-allocated buffer
860− for range f.gap {
1026+ if f.gap > 0 {
861− b.WriteByte('\n')
1027+ if f.gap <= maxGapSize {
1028+ b.WriteString(newlineBuffer[:f.gap])
1030+ b.WriteString(strings.Repeat("\n", f.gap))
@@ -867,10 +1037,15 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
868 // iterate backwards as fragments are in reversed order
1038 // iterate backwards as fragments are in reversed order
869 for i := len(fragments) - 1; i >= 0; i-- {
1039 for i := len(fragments) - 1; i >= 0; i-- {
870− f := fragments[i]
1040+ f := &fragments[i]
871 b.WriteString(f.view)
1041 b.WriteString(f.view)
1042+ // Optimized gap writing using pre-allocated buffer
872− for range f.gap {
1043+ if f.gap > 0 {
873− b.WriteByte('\n')
1044+ if f.gap <= maxGapSize {
1045+ b.WriteString(newlineBuffer[:f.gap])
1047+ b.WriteString(strings.Repeat("\n", f.gap))
876 b.WriteString(rendered)
1051 b.WriteString(rendered)
@@ -881,7 +1056,6 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
881func (l *list[T]) renderItem(item Item) renderedItem {
1056func (l *list[T]) renderItem(item Item) renderedItem {
882 view := item.View()
1057 view := item.View()
883 return renderedItem{
1058 return renderedItem{
885 view: view,
1059 view: view,
886 height: lipgloss.Height(view),
1060 height: lipgloss.Height(view),
@@ -889,17 +1063,17 @@ func (l *list[T]) renderItem(item Item) renderedItem {
890// AppendItem implements List.
1064// AppendItem implements List.
891func (l *list[T]) AppendItem(item T) tea.Cmd {
1065func (l *list[T]) AppendItem(item T) tea.Cmd {
1066+ // Pre-allocate with expected capacity
892− var cmds []tea.Cmd
1067+ cmds := make([]tea.Cmd, 0, 4)
893 cmd := item.Init()
1068 cmd := item.Init()
894 if cmd != nil {
1069 if cmd != nil {
895 cmds = append(cmds, cmd)
1070 cmds = append(cmds, cmd)
898− l.items.Append(item)
1073+ newIndex := len(l.items)
899− l.indexMap = csync.NewMap[string, int]()
1074+ l.items = append(l.items, item)
900− for inx, item := range slices.Collect(l.items.Seq()) {
901− l.indexMap.Set(item.ID(), inx)
1075+ l.indexMap[item.ID()] = newIndex
903 if l.width > 0 && l.height > 0 {
1077 if l.width > 0 && l.height > 0 {
904 cmd = item.SetSize(l.width, l.height)
1078 cmd = item.SetSize(l.width, l.height)
905 if cmd != nil {
1079 if cmd != nil {
@@ -917,13 +1091,13 @@ func (l *list[T]) AppendItem(item T) tea.Cmd {
917 cmds = append(cmds, cmd)
1091 cmds = append(cmds, cmd)
919 } else {
1093 } else {
920− newItem, ok := l.renderedItems.Get(item.ID())
1094+ newItem, ok := l.renderedItems[item.ID()]
922 newLines := newItem.height
1096 newLines := newItem.height
923− if l.items.Len() > 1 {
1097+ if len(l.items) > 1 {
924 newLines += l.gap
1098 newLines += l.gap
926− l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1100+ l.offset = min(l.renderedHeight-1, l.offset+newLines)
@@ -938,35 +1112,41 @@ func (l *list[T]) Blur() tea.Cmd {
939// DeleteItem implements List.
1113// DeleteItem implements List.
940func (l *list[T]) DeleteItem(id string) tea.Cmd {
1114func (l *list[T]) DeleteItem(id string) tea.Cmd {
941− inx, ok := l.indexMap.Get(id)
1115+ inx, ok := l.indexMap[id]
942 if !ok {
1116 if !ok {
943 return nil
1117 return nil
945− l.items.Delete(inx)
1119+ l.items = append(l.items[:inx], l.items[inx+1:]...)
946− l.renderedItems.Del(id)
1120+ delete(l.renderedItems, id)
947− for inx, item := range slices.Collect(l.items.Seq()) {
1121+ delete(l.indexMap, id)
948− l.indexMap.Set(item.ID(), inx)
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) {
1128+ l.indexMap[item.ID()] = i
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
952 if inx > 0 {
1135 if inx > 0 {
953− item, ok := l.items.Get(inx - 1)
1136+ l.selectedItemIdx = inx - 1
955− l.selectedItem = item.ID()
959 } else {
1137 } else {
960− l.selectedItem = ""
1138+ l.selectedItemIdx = -1
1140+ } else if l.selectedItemIdx > inx {
1141+ // Selected item is after the deleted one, shift index down
1142+ l.selectedItemIdx--
963 cmd := l.render()
1144 cmd := l.render()
964 if l.rendered != "" {
1145 if l.rendered != "" {
965− renderedHeight := lipgloss.Height(l.rendered)
966− if renderedHeight <= l.height {
1146+ if l.renderedHeight <= l.height {
967 l.offset = 0
1147 l.offset = 0
968 } else {
1148 } else {
969− maxOffset := renderedHeight - l.height
1149+ maxOffset := l.renderedHeight - l.height
970 if l.offset > maxOffset {
1150 if l.offset > maxOffset {
971 l.offset = maxOffset
1151 l.offset = maxOffset
@@ -989,7 +1169,7 @@ func (l *list[T]) GetSize() (int, int) {
989// GoToBottom implements List.
1169// GoToBottom implements List.
990func (l *list[T]) GoToBottom() tea.Cmd {
1170func (l *list[T]) GoToBottom() tea.Cmd {
991 l.offset = 0
1171 l.offset = 0
992− l.selectedItem = ""
1172+ l.selectedItemIdx = -1
993 l.direction = DirectionBackward
1173 l.direction = DirectionBackward
994 return l.render()
1174 return l.render()
@@ -997,7 +1177,7 @@ func (l *list[T]) GoToBottom() tea.Cmd {
997// GoToTop implements List.
1177// GoToTop implements List.
998func (l *list[T]) GoToTop() tea.Cmd {
1178func (l *list[T]) GoToTop() tea.Cmd {
999 l.offset = 0
1179 l.offset = 0
1000− l.selectedItem = ""
1180+ l.selectedItemIdx = -1
1001 l.direction = DirectionForward
1181 l.direction = DirectionForward
1002 return l.render()
1182 return l.render()
@@ -1009,21 +1189,29 @@ func (l *list[T]) IsFocused() bool {
1010// Items implements List.
1190// Items implements List.
1011func (l *list[T]) Items() []T {
1191func (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) {
1197+ result = append(result, item)
1015func (l *list[T]) incrementOffset(n int) {
1203func (l *list[T]) incrementOffset(n int) {
1016− renderedHeight := lipgloss.Height(l.rendered)
1017 // no need for offset
1204 // no need for offset
1018− if renderedHeight <= l.height {
1205+ if l.renderedHeight <= l.height {
1021− maxOffset := renderedHeight - l.height
1208+ maxOffset := l.renderedHeight - l.height
1022 n = min(n, maxOffset-l.offset)
1209 n = min(n, maxOffset-l.offset)
1023 if n <= 0 {
1210 if n <= 0 {
1026 l.offset += n
1213 l.offset += n
1214+ l.cachedViewDirty = true
1029func (l *list[T]) decrementOffset(n int) {
1217func (l *list[T]) decrementOffset(n int) {
@@ -1035,6 +1223,7 @@ func (l *list[T]) decrementOffset(n int) {
1035 if l.offset < 0 {
1223 if l.offset < 0 {
1036 l.offset = 0
1224 l.offset = 0
1226+ l.cachedViewDirty = true
1040// MoveDown implements List.
1229// MoveDown implements List.
@@ -1105,14 +1294,26 @@ func (l *list[T]) MoveUp(n int) tea.Cmd {
1106// PrependItem implements List.
1295// PrependItem implements List.
1107func (l *list[T]) PrependItem(item T) tea.Cmd {
1296func (l *list[T]) PrependItem(item T) tea.Cmd {
1297+ // Pre-allocate with expected capacity
1108− cmds := []tea.Cmd{
1298+ cmds := make([]tea.Cmd, 0, 4)
1109− item.Init(),
1299+ cmds = append(cmds, item.Init())
1301+ l.items = append([]T{item}, l.items...)
1303+ // Shift selectedItemIdx since all items moved down by 1
1304+ if l.selectedItemIdx >= 0 {
1305+ l.selectedItemIdx++
1111− l.items.Prepend(item)
1307+
1112− l.indexMap = csync.NewMap[string, int]()
1308+ // Update index map incrementally: shift all existing indices up by 1
1113− for inx, item := range slices.Collect(l.items.Seq()) {
1309+ // This is more efficient than rebuilding from scratch
1114− l.indexMap.Set(item.ID(), inx)
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
1314+ newIndexMap[item.ID()] = 0 // New item is at index 0
1315+ l.indexMap = newIndexMap
1116 if l.width > 0 && l.height > 0 {
1317 if l.width > 0 && l.height > 0 {
1117 cmds = append(cmds, item.SetSize(l.width, l.height))
1318 cmds = append(cmds, item.SetSize(l.width, l.height))
@@ -1124,13 +1325,13 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
1124 cmds = append(cmds, cmd)
1325 cmds = append(cmds, cmd)
1126 } else {
1327 } else {
1127− newItem, ok := l.renderedItems.Get(item.ID())
1328+ newItem, ok := l.renderedItems[item.ID()]
1129 newLines := newItem.height
1330 newLines := newItem.height
1130− if l.items.Len() > 1 {
1331+ if len(l.items) > 1 {
1131 newLines += l.gap
1332 newLines += l.gap
1133− l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1334+ l.offset = min(l.renderedHeight-1, l.offset+newLines)
@@ -1139,17 +1340,17 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
1140// SelectItemAbove implements List.
1341// SelectItemAbove implements List.
1141func (l *list[T]) SelectItemAbove() tea.Cmd {
1342func (l *list[T]) SelectItemAbove() tea.Cmd {
1142− inx, ok := l.indexMap.Get(l.selectedItem)
1343+ if l.selectedItemIdx < 0 {
1144 return nil
1344 return nil
1147− newIndex := l.firstSelectableItemAbove(inx)
1347+ newIndex := l.firstSelectableItemAbove(l.selectedItemIdx)
1148 if newIndex == ItemNotFound {
1348 if newIndex == ItemNotFound {
1149 // no item above
1349 // no item above
1150 return nil
1350 return nil
1352+ // Pre-allocate with expected capacity
1152− var cmds []tea.Cmd
1353+ cmds := make([]tea.Cmd, 0, 2)
1153 if newIndex == 1 {
1354 if newIndex == 1 {
1154 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1355 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
1155 if peakAboveIndex == ItemNotFound {
1356 if peakAboveIndex == ItemNotFound {
@@ -1160,11 +1361,11 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
1163− item, ok := l.items.Get(newIndex)
1364+ if newIndex < 0 || newIndex >= len(l.items) {
1165 return nil
1365 return nil
1167− l.selectedItem = item.ID()
1367+ l.prevSelectedItemIdx = l.selectedItemIdx
1368+ l.selectedItemIdx = newIndex
1168 l.movingByItem = true
1369 l.movingByItem = true
1169 renderCmd := l.render()
1370 renderCmd := l.render()
1170 if renderCmd != nil {
1371 if renderCmd != nil {
@@ -1175,46 +1376,38 @@ func (l *list[T]) SelectItemAbove() tea.Cmd {
1176// SelectItemBelow implements List.
1377// SelectItemBelow implements List.
1177func (l *list[T]) SelectItemBelow() tea.Cmd {
1378func (l *list[T]) SelectItemBelow() tea.Cmd {
1178− inx, ok := l.indexMap.Get(l.selectedItem)
1379+ if l.selectedItemIdx < 0 {
1180 return nil
1380 return nil
1183− newIndex := l.firstSelectableItemBelow(inx)
1383+ newIndex := l.firstSelectableItemBelow(l.selectedItemIdx)
1184 if newIndex == ItemNotFound {
1384 if newIndex == ItemNotFound {
1185 // no item above
1385 // no item above
1186 return nil
1386 return nil
1188− item, ok := l.items.Get(newIndex)
1388+ if newIndex < 0 || newIndex >= len(l.items) {
1190 return nil
1389 return nil
1192− l.selectedItem = item.ID()
1391+ l.prevSelectedItemIdx = l.selectedItemIdx
1392+ l.selectedItemIdx = newIndex
1193 l.movingByItem = true
1393 l.movingByItem = true
1194 return l.render()
1394 return l.render()
1197// SelectedItem implements List.
1397// SelectedItem implements List.
1198func (l *list[T]) SelectedItem() *T {
1398func (l *list[T]) SelectedItem() *T {
1199− inx, ok := l.indexMap.Get(l.selectedItem)
1399+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
1203− if inx > l.items.Len()-1 {
1206− item, ok := l.items.Get(inx)
1208 return nil
1400 return nil
1402+ item := l.items[l.selectedItemIdx]
1210 return &item
1403 return &item
1213// SetItems implements List.
1406// SetItems implements List.
1214func (l *list[T]) SetItems(items []T) tea.Cmd {
1407func (l *list[T]) SetItems(items []T) tea.Cmd {
1215− l.items.SetSlice(items)
1408+ l.items = items
1216 var cmds []tea.Cmd
1409 var cmds []tea.Cmd
1217− for inx, item := range slices.Collect(l.items.Seq()) {
1410+ for inx, item := range items {
1218 if i, ok := any(item).(Indexable); ok {
1411 if i, ok := any(item).(Indexable); ok {
1219 i.SetIndex(inx)
1412 i.SetIndex(inx)
@@ -1226,23 +1419,44 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
1227// SetSelected implements List.
1420// SetSelected implements List.
1228func (l *list[T]) SetSelected(id string) tea.Cmd {
1421func (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
1426+ l.selectedItemIdx = -1
1230 return l.render()
1428 return l.render()
1233−func (l *list[T]) reset(selectedItem string) tea.Cmd {
1431+func (l *list[T]) reset(selectedItemID string) tea.Cmd {
1234 var cmds []tea.Cmd
1432 var cmds []tea.Cmd
1235 l.rendered = ""
1433 l.rendered = ""
1434+ l.renderedHeight = 0
1236 l.offset = 0
1435 l.offset = 0
1237− l.selectedItem = selectedItem
1238− l.indexMap = csync.NewMap[string, int]()
1436+ l.indexMap = make(map[string]int)
1239− l.renderedItems = csync.NewMap[string, renderedItem]()
1437+ l.renderedItems = make(map[string]renderedItem)
1240− for inx, item := range slices.Collect(l.items.Seq()) {
1438+ itemsLen := len(l.items)
1439+ for i := range itemsLen {
1440+ if i < 0 || i >= len(l.items) {
1241− l.indexMap.Set(item.ID(), inx)
1445+ l.indexMap[item.ID()] = i
1242 if l.width > 0 && l.height > 0 {
1446 if l.width > 0 && l.height > 0 {
1243 cmds = append(cmds, item.SetSize(l.width, l.height))
1447 cmds = append(cmds, item.SetSize(l.width, l.height))
1450+ // Convert selectedItemID to index after rebuilding indexMap
1451+ if selectedItemID != "" {
1452+ if idx, ok := l.indexMap[selectedItemID]; ok {
1453+ l.selectedItemIdx = idx
1455+ l.selectedItemIdx = -1
1458+ l.selectedItemIdx = -1
1246 cmds = append(cmds, l.render())
1460 cmds = append(cmds, l.render())
1247 return tea.Batch(cmds...)
1461 return tea.Batch(cmds...)
@@ -1253,7 +1467,13 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1253 l.width = width
1467 l.width = width
1254 l.height = height
1468 l.height = height
1255 if oldWidth != width {
1469 if oldWidth != width {
1470+ // Get current selected item ID before reset
1472+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
1473+ item := l.items[l.selectedItemIdx]
1474+ selectedID = item.ID()
1256− cmd := l.reset(l.selectedItem)
1476+ cmd := l.reset(selectedID)
1257 return cmd
1477 return cmd
1259 return nil
1479 return nil
@@ -1261,16 +1481,17 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1262// UpdateItem implements List.
1482// UpdateItem implements List.
1263func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1483func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
1484+ // Pre-allocate with expected capacity
1264− var cmds []tea.Cmd
1485+ cmds := make([]tea.Cmd, 0, 1)
1265− if inx, ok := l.indexMap.Get(id); ok {
1486+ if inx, ok := l.indexMap[id]; ok {
1266− l.items.Set(inx, item)
1487+ l.items[inx] = item
1267− oldItem, hasOldItem := l.renderedItems.Get(id)
1488+ oldItem, hasOldItem := l.renderedItems[id]
1268 oldPosition := l.offset
1489 oldPosition := l.offset
1269 if l.direction == DirectionBackward {
1490 if l.direction == DirectionBackward {
1270− oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
1491+ oldPosition = (l.renderedHeight - 1) - l.offset
1273− l.renderedItems.Del(id)
1494+ delete(l.renderedItems, id)
1274 cmd := l.render()
1495 cmd := l.render()
1276 // need to check for nil because of sequence not handling nil
1497 // 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 {
1281 // if we are the last item and there is no offset
1502 // if we are the last item and there is no offset
1282 // make sure to go to the bottom
1503 // make sure to go to the bottom
1283 if oldPosition < oldItem.end {
1504 if oldPosition < oldItem.end {
1284− newItem, ok := l.renderedItems.Get(item.ID())
1505+ newItem, ok := l.renderedItems[item.ID()]
1286 newLines := newItem.height - oldItem.height
1507 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)
1290 } else if hasOldItem && l.offset > oldItem.start {
1511 } else if hasOldItem && l.offset > oldItem.start {
1291− newItem, ok := l.renderedItems.Get(item.ID())
1512+ newItem, ok := l.renderedItems[item.ID()]
1293 newLines := newItem.height - oldItem.height
1514 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)
@@ -1333,13 +1554,10 @@ func (l *list[T]) SelectionClear() {
1335func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1556func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1336− lines := strings.Split(l.rendered, "\n")
1557+ numLines := l.lineCount()
1337− for i, l := range lines {
1338− lines[i] = ansi.Strip(l)
1341− if l.direction == DirectionBackward && len(lines) > l.height {
1559+ if l.direction == DirectionBackward && numLines > l.height {
1342− line = ((len(lines) - 1) - l.height) + line + 1
1560+ line = ((numLines - 1) - l.height) + line + 1
1345 if l.offset > 0 {
1563 if l.offset > 0 {
@@ -1350,11 +1568,11 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1353− if line < 0 || line >= len(lines) {
1571+ if line < 0 || line >= numLines {
1354 return 0, 0
1572 return 0, 0
1357− currentLine := lines[line]
1575+ currentLine := ansi.Strip(l.getLine(line))
1358 gr := uniseg.NewGraphemes(currentLine)
1576 gr := uniseg.NewGraphemes(currentLine)
1359 startCol = -1
1577 startCol = -1
1360 upTo := col
1578 upTo := col
@@ -1377,15 +1595,19 @@ func (l *list[T]) findWordBoundaries(col, line int) (startCol, endCol int) {
1379func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1597func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, found bool) {
1380− lines := strings.Split(l.rendered, "\n")
1598+ // Helper function to get a line with ANSI stripped and icons replaced
1381− for i, l := range lines {
1599+ getCleanLine := func(index int) string {
1600+ rawLine := l.getLine(index)
1382− lines[i] = ansi.Strip(l)
1601+ cleanLine := ansi.Strip(rawLine)
1383 for _, icon := range styles.SelectionIgnoreIcons {
1602 for _, icon := range styles.SelectionIgnoreIcons {
1384− lines[i] = strings.ReplaceAll(lines[i], icon, " ")
1603+ cleanLine = strings.ReplaceAll(cleanLine, icon, " ")
1608+ numLines := l.lineCount()
1387− if l.direction == DirectionBackward && len(lines) > l.height {
1609+ if l.direction == DirectionBackward && numLines > l.height {
1388− line = (len(lines) - 1) - l.height + line + 1
1610+ line = (numLines - 1) - l.height + line + 1
1391 if l.offset > 0 {
1613 if l.offset > 0 {
@@ -1397,30 +1619,30 @@ func (l *list[T]) findParagraphBoundaries(line int) (startLine, endLine int, fou
1399 // Ensure line is within bounds
1621 // Ensure line is within bounds
1400− if line < 0 || line >= len(lines) {
1622+ if line < 0 || line >= numLines {
1401 return 0, 0, false
1623 return 0, 0, false
1404− if strings.TrimSpace(lines[line]) == "" {
1626+ if strings.TrimSpace(getCleanLine(line)) == "" {
1405 return 0, 0, false
1627 return 0, 0, false
1408 // Find start of paragraph (search backwards for empty line or start of text)
1630 // Find start of paragraph (search backwards for empty line or start of text)
1409 startLine = line
1631 startLine = line
1410− for startLine > 0 && strings.TrimSpace(lines[startLine-1]) != "" {
1632+ for startLine > 0 && strings.TrimSpace(getCleanLine(startLine-1)) != "" {
1411 startLine--
1633 startLine--
1414 // Find end of paragraph (search forwards for empty line or end of text)
1636 // Find end of paragraph (search forwards for empty line or end of text)
1415 endLine = line
1637 endLine = line
1416− for endLine < len(lines)-1 && strings.TrimSpace(lines[endLine+1]) != "" {
1638+ for endLine < numLines-1 && strings.TrimSpace(getCleanLine(endLine+1)) != "" {
1417 endLine++
1639 endLine++
1420 // revert the line numbers if we are in backward direction
1642 // revert the line numbers if we are in backward direction
1421− if l.direction == DirectionBackward && len(lines) > l.height {
1643+ if l.direction == DirectionBackward && numLines > l.height {
1422− startLine = startLine - (len(lines) - 1) + l.height - 1
1644+ startLine = startLine - (numLines - 1) + l.height - 1
1423− endLine = endLine - (len(lines) - 1) + l.height - 1
1645+ endLine = endLine - (numLines - 1) + l.height - 1
1425 if l.offset > 0 {
1647 if l.offset > 0 {
1426 if l.direction == DirectionBackward {
1648 if l.direction == DirectionBackward {