on main help sign in
List performance improvements (#1325)
be68f48Kujtim Hoxha
internal/tui/exp/list/filterable.go+2−2
@@ -102,7 +102,7 @@ func NewFilterableList[T FilterableItem](items []T, opts ...filterableListOption
102 f.list = New(items, f.listOptions...).(*list[T])
102 f.list = New(items, f.listOptions...).(*list[T])
103
103
104 f.updateKeyMaps()
104 f.updateKeyMaps()
105− f.items = slices.Collect(f.list.items.Seq())
105+ f.items = f.list.items
106
106
107 if f.inputHidden {
107 if f.inputHidden {
108 return f
108 return f
@@ -243,7 +243,7 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
243 }
243 }
244 }
244 }
245
245
246− f.selectedItem = ""
246+ f.selectedItemIdx = -1
247 if query == "" || len(f.items) == 0 {
247 if query == "" || len(f.items) == 0 {
248 return f.list.SetItems(f.items)
248 return f.list.SetItems(f.items)
249 }
249 }
internal/tui/exp/list/filterable_group.go+2−3
@@ -2,7 +2,6 @@ package list
2
2
3import (
3import (
4 "regexp"
4 "regexp"
5− "slices"
6 "sort"
5 "sort"
7 "strings"
6 "strings"
8
7
@@ -183,7 +182,7 @@ func (f *filterableGroupList[T]) inputHeight() int {
183
182
184func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
183func (f *filterableGroupList[T]) clearItemState() []tea.Cmd {
185 var cmds []tea.Cmd
184 var cmds []tea.Cmd
186− for _, item := range slices.Collect(f.items.Seq()) {
185+ for _, item := range f.items {
187 if i, ok := any(item).(layout.Focusable); ok {
186 if i, ok := any(item).(layout.Focusable); ok {
188 cmds = append(cmds, i.Blur())
187 cmds = append(cmds, i.Blur())
189 }
188 }
@@ -253,7 +252,7 @@ func (f *filterableGroupList[T]) filterItemsInGroup(group Group[T], query string
253
252
254func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
253func (f *filterableGroupList[T]) Filter(query string) tea.Cmd {
255 cmds := f.clearItemState()
254 cmds := f.clearItemState()
256− f.selectedItem = ""
255+ f.selectedItemIdx = -1
257
256
258 if query == "" {
257 if query == "" {
259 return f.groupedList.SetGroups(f.groups)
258 return f.groupedList.SetGroups(f.groups)
internal/tui/exp/list/filterable_test.go+1−1
@@ -29,7 +29,7 @@ func TestFilterableList(t *testing.T) {
29 cmd()
29 cmd()
30 }
30 }
31
31
32− assert.Equal(t, items[0].ID(), l.selectedItem)
32+ assert.Equal(t, 0, l.selectedItemIdx)
33 golden.RequireEqual(t, []byte(l.View()))
33 golden.RequireEqual(t, []byte(l.View()))
34 })
34 })
35}
35}
internal/tui/exp/list/grouped.go+5−8
@@ -1,10 +1,7 @@
1package list
1package list
2
2
3import (
3import (
4− "slices"
5−
6 tea "github.com/charmbracelet/bubbletea/v2"
4 tea "github.com/charmbracelet/bubbletea/v2"
7− "github.com/charmbracelet/crush/internal/csync"
8 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
5 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
9 "github.com/charmbracelet/crush/internal/tui/util"
6 "github.com/charmbracelet/crush/internal/tui/util"
10)
7)
@@ -40,9 +37,9 @@ func NewGroupedList[T Item](groups []Group[T], opts ...ListOption) GroupedList[T
40 keyMap: DefaultKeyMap(),
37 keyMap: DefaultKeyMap(),
41 focused: true,
38 focused: true,
42 },
39 },
43− items: csync.NewSlice[Item](),
40+ items: []Item{},
44− indexMap: csync.NewMap[string, int](),
41+ indexMap: make(map[string]int),
45− renderedItems: csync.NewMap[string, renderedItem](),
42+ renderedItems: make(map[string]renderedItem),
46 }
43 }
47 for _, opt := range opts {
44 for _, opt := range opts {
48 opt(list.confOptions)
45 opt(list.confOptions)
@@ -85,13 +82,13 @@ func (g *groupedList[T]) convertItems() {
85 items = append(items, g)
82 items = append(items, g)
86 }
83 }
87 }
84 }
88− g.items.SetSlice(items)
85+ g.items = items
89}
86}
90
87
91func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
88func (g *groupedList[T]) SetGroups(groups []Group[T]) tea.Cmd {
92 g.groups = groups
89 g.groups = groups
93 g.convertItems()
90 g.convertItems()
94− return g.SetItems(slices.Collect(g.items.Seq()))
91+ return g.SetItems(g.items)
95}
92}
96
93
97func (g *groupedList[T]) Groups() []Group[T] {
94func (g *groupedList[T]) Groups() []Group[T] {
internal/tui/exp/list/list.go+491−269
@@ -1,13 +1,11 @@
1package list
1package list
2
2
3import (
3import (
4− "slices"
5 "strings"
4 "strings"
6 "sync"
5 "sync"
7
6
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)
18)
21
19
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+
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
37
54
38− // Just change state
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 (
69
85
70const (
86const (
71 ItemNotFound = -1
87 ItemNotFound = -1
72− ViewportDefaultScrollSize = 2
88+ ViewportDefaultScrollSize = 5
73)
89)
74
90
75type renderedItem struct {
91type renderedItem struct {
76− id string
77 view string
92 view string
78 height int
93 height int
79 start int
94 start int
@@ -81,16 +96,16 @@ type renderedItem struct {
81}
96}
82
97
83type confOptions struct {
98type confOptions struct {
84− width, height int
99+ width, height int
85− gap int
100+ gap 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
94}
109}
95
110
96type list[T Item] struct {
111type list[T Item] struct {
@@ -98,19 +113,24 @@ type list[T Item] struct {
98
113
99 offset int
114 offset int
100
115
101− indexMap *csync.Map[string, int]
116+ indexMap map[string]int
102− items *csync.Slice[T]
117+ items []T
118+ renderedItems map[string]renderedItem
103
119
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)
105
123
106− renderMu sync.Mutex
107− rendered string
124+ cachedView string
125+ cachedViewOffset int
126+ cachedViewDirty bool
108
127
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
114
134
115 selectionActive bool
135 selectionActive bool
116}
136}
@@ -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()
153 }
173 }
154}
174}
155
175
@@ -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,
192 },
213 },
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,
200 }
222 }
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)
208 }
230 }
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
210 }
240 }
241+
211 return list
242 return list
212}
243}
213
244
@@ -225,10 +256,25 @@ func (l *list[T]) Update(msg tea.Msg) (util.Model, tea.Cmd) {
225 }
256 }
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 {
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+
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) {
273+ continue
274+ }
275+ item := l.items[i]
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
289}
335}
290
336
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 {
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+
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 {
302 }
356 }
303 selArea = selArea.Canon()
357 selArea = selArea.Canon()
304
358
305− specialChars := make(map[string]bool, len(styles.SelectionIgnoreIcons))
359+ specialChars := getSpecialCharsMap()
306− for _, icon := range styles.SelectionIgnoreIcons {
307− specialChars[icon] = true
308− }
309
360
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 {
366 }
417 }
367
418
368 char := rune(cellStr[0])
419 char := rune(cellStr[0])
369− isSpecial := specialChars[cellStr]
420+ _, isSpecial := specialChars[cellStr]
370
421
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 {
409 }
460 }
410
461
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 {
465+ continue
466+ }
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()
440}
494}
441
495
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 ""
446 }
499 }
500+
501+ if !l.cachedViewDirty && l.cachedViewOffset == l.offset && !l.hasSelection() && l.cachedView != "" {
502+ return l.cachedView
503+ }
504+
447 t := styles.CurrentTheme()
505 t := styles.CurrentTheme()
448− view := l.rendered
449− lines := strings.Split(view, "\n")
450
506
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
454
510
455 if viewStart > viewEnd {
511 if viewStart > viewEnd {
456− viewStart = viewEnd
512+ return ""
457 }
513 }
514+
458− lines = lines[viewStart:viewEnd]
515+ view := l.getLines(viewStart, viewEnd)
459
516
460 if l.resize {
517 if l.resize {
461− return strings.Join(lines, "\n")
518+ return view
462 }
519 }
520+
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)
467
525
468 if !l.hasSelection() {
526 if !l.hasSelection() {
527+ l.cachedView = view
528+ l.cachedViewOffset = l.offset
529+ l.cachedViewDirty = false
469 return view
530 return view
470 }
531 }
471
532
@@ -474,7 +535,7 @@ func (l *list[T]) View() string {
474
535
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
487}
548}
488
549
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+
489func (l *list[T]) recalculateItemPositions() {
629func (l *list[T]) recalculateItemPositions() {
630+ l.recalculateItemPositionsFrom(0)
631+}
632+
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
640+ }
641+ }
642+
643+ for i := startIdx; i < len(l.items); i++ {
644+ item := l.items[i]
492− rItem, ok := l.renderedItems.Get(item.ID())
645+ rItem, ok := l.renderedItems[item.ID()]
493 if !ok {
646 if !ok {
494 continue
647 continue
495 }
648 }
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
500 }
653 }
501}
654}
502
655
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
506 }
659 }
507 l.setDefaultSelected()
660 l.setDefaultSelected()
@@ -512,51 +665,38 @@ func (l *list[T]) render() tea.Cmd {
512 } else {
665 } else {
513 focusChangeCmd = l.blurSelectedItem()
666 focusChangeCmd = l.blurSelectedItem()
514 }
667 }
515− // we are not rendering the first time
516 if l.rendered != "" {
668 if l.rendered != "" {
517− // rerender everything will mostly hit cache
518− l.renderMu.Lock()
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()
523 }
673 }
524− // in the end scroll to the selected item
525 if l.focused {
674 if l.focused {
526 l.scrollToSelection()
675 l.scrollToSelection()
527 }
676 }
528 return focusChangeCmd
677 return focusChangeCmd
529 }
678 }
530− l.renderMu.Lock()
531 rendered, finishIndex := l.renderIterator(0, true, "")
679 rendered, finishIndex := l.renderIterator(0, true, "")
532− l.rendered = rendered
680+ l.setRendered(rendered)
533− l.renderMu.Unlock()
534− // recalculate for the initial items
535 if l.direction == DirectionBackward {
681 if l.direction == DirectionBackward {
536 l.recalculateItemPositions()
682 l.recalculateItemPositions()
537 }
683 }
538− renderCmd := func() tea.Msg {
539− l.offset = 0
540− // render the rest
541
684
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()
548− }
549− // in the end scroll to the selected item
550− if l.focused {
551− l.scrollToSelection()
552− }
553− return nil
554 }
690 }
691+ if l.focused {
692+ l.scrollToSelection()
693+ }
694+
555− return tea.Batch(focusChangeCmd, renderCmd)
695+ return focusChangeCmd
556}
696}
557
697
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()
562 } else {
702 } else {
@@ -566,27 +706,29 @@ func (l *list[T]) setDefaultSelected() {
566}
706}
567
707
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()
712+ return
713+ }
714+ item := l.items[l.selectedItemIdx]
569− rItem, ok := l.renderedItems.Get(l.selectedItem)
715+ rItem, ok := l.renderedItems[item.ID()]
570 if !ok {
716 if !ok {
571− l.selectedItem = ""
717+ l.selectedItemIdx = -1
572 l.setDefaultSelected()
718 l.setDefaultSelected()
573 return
719 return
574 }
720 }
575
721
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 {
579 return
724 return
580 }
725 }
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 {
585 return
728 return
586 }
729 }
587 defer func() { l.movingByItem = false }()
730 defer func() { l.movingByItem = false }()
588 } else {
731 } else {
589− // item already in view do nothing
590 if rItem.start >= start && rItem.start <= end {
732 if rItem.start >= start && rItem.start <= end {
591 return
733 return
592 }
734 }
@@ -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
601 } else {
743 } 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))
603 }
745 }
604 return
746 return
605 }
747 }
606
748
607− renderedLines := lipgloss.Height(l.rendered) - 1
749+ renderedLines := l.renderedHeight - 1
608
750
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)
615 }
756 }
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)
620 } else {
760 } else {
@@ -624,7 +764,11 @@ func (l *list[T]) scrollToSelection() {
624}
764}
625
765
626func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
766func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
767+ if l.selectedItemIdx < 0 || l.selectedItemIdx >= len(l.items) {
768+ return nil
769+ }
770+ item := l.items[l.selectedItemIdx]
627− rItem, ok := l.renderedItems.Get(l.selectedItem)
771+ rItem, ok := l.renderedItems[item.ID()]
628 if !ok {
772 if !ok {
629 return nil
773 return nil
630 }
774 }
@@ -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
647− if !ok {
648− return nil
649− }
650 for {
791 for {
651 inx = l.firstSelectableItemBelow(inx)
792 inx = l.firstSelectableItemBelow(inx)
652 if inx == ItemNotFound {
793 if inx == ItemNotFound {
653 return nil
794 return nil
654 }
795 }
655− item, ok := l.items.Get(inx)
796+ if inx < 0 || inx >= len(l.items) {
656− if !ok {
657 continue
797 continue
658 }
798 }
799+
800+ item := l.items[inx]
659− renderedItem, ok := l.renderedItems.Get(item.ID())
801+ renderedItem, ok := l.renderedItems[item.ID()]
660 if !ok {
802 if !ok {
661 continue
803 continue
662 }
804 }
663
805
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()
668 }
810 }
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()
673 }
815 }
674 }
816 }
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
679− if !ok {
680− return nil
681− }
682 for {
821 for {
683 inx = l.firstSelectableItemAbove(inx)
822 inx = l.firstSelectableItemAbove(inx)
684 if inx == ItemNotFound {
823 if inx == ItemNotFound {
685 return nil
824 return nil
686 }
825 }
687− item, ok := l.items.Get(inx)
826+ if inx < 0 || inx >= len(l.items) {
688− if !ok {
689 continue
827 continue
690 }
828 }
829+
830+ item := l.items[inx]
691− renderedItem, ok := l.renderedItems.Get(item.ID())
831+ renderedItem, ok := l.renderedItems[item.ID()]
692 if !ok {
832 if !ok {
693 continue
833 continue
694 }
834 }
695
835
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()
700 }
840 }
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()
705 }
845 }
706 }
846 }
@@ -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
715− if ok {
716− l.selectedItem = item.ID()
717− }
718 }
855 }
719}
856}
720
857
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
725− if ok {
726− l.selectedItem = item.ID()
727− }
728 }
862 }
729}
863}
730
864
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) {
734− if !ok {
735 continue
868 continue
736 }
869 }
870+
871+ item := l.items[i]
737 if _, ok := any(item).(layout.Focusable); ok {
872 if _, ok := any(item).(layout.Focusable); ok {
738 return i
873 return i
739 }
874 }
740 }
875 }
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))
743 }
878 }
744 return ItemNotFound
879 return ItemNotFound
745}
880}
746
881
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) {
751− if !ok {
752 continue
886 continue
753 }
887 }
888+
889+ item := l.items[i]
754 if _, ok := any(item).(layout.Focusable); ok {
890 if _, ok := any(item).(layout.Focusable); ok {
755 return i
891 return i
756 }
892 }
@@ -762,38 +898,52 @@ func (l *list[T]) firstSelectableItemBelow(inx int) int {
762}
898}
763
899
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
767 }
903 }
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())
777− }
778 }
915 }
779 }
916 }
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
780 return tea.Batch(cmds...)
929 return tea.Batch(cmds...)
781}
930}
782
931
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
786 }
935 }
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()
794 }
943 }
795 }
944 }
945+
796− return tea.Batch(cmds...)
946+ return nil
797}
947}
798
948
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)
964+ }
965+ fragments := make([]renderFragment, 0, expectedFragments)
810
966
811 currentContentHeight := lipgloss.Height(rendered) - 1
967 currentContentHeight := lipgloss.Height(rendered) - 1
812− itemsLen := l.items.Len()
813 finalIndex := itemsLen
968 finalIndex := itemsLen
814
969
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
827 }
982 }
828
983
829− item, ok := l.items.Get(inx)
984+ if inx < 0 || inx >= len(l.items) {
830− if !ok {
831 continue
985 continue
832 }
986 }
833
987
988+ item := l.items[inx]
989+
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
837 } else {
993 } else {
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
842 }
998 }
843
999
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
853
1009
854 // second pass: build rendered string efficiently
1010 // second pass: build rendered string efficiently
855 var b strings.Builder
1011 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+
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 {
1023+ f := &fragments[i]
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])
1029+ } else {
1030+ b.WriteString(strings.Repeat("\n", f.gap))
1031+ }
862 }
1032 }
863 }
1033 }
864
1034
@@ -867,10 +1037,15 @@ func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string
867
1037
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])
1046+ } else {
1047+ b.WriteString(strings.Repeat("\n", f.gap))
1048+ }
874 }
1049 }
875 }
1050 }
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{
884− id: item.ID(),
885 view: view,
1059 view: view,
886 height: lipgloss.Height(view),
1060 height: lipgloss.Height(view),
887 }
1061 }
@@ -889,17 +1063,17 @@ func (l *list[T]) renderItem(item Item) renderedItem {
889
1063
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)
896 }
1071 }
897
1072
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
902− }
1076+
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)
918 }
1092 }
919 } else {
1093 } else {
920− newItem, ok := l.renderedItems.Get(item.ID())
1094+ newItem, ok := l.renderedItems[item.ID()]
921 if ok {
1095 if ok {
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
925 }
1099 }
926− l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1100+ l.offset = min(l.renderedHeight-1, l.offset+newLines)
927 }
1101 }
928 }
1102 }
929 }
1103 }
@@ -938,35 +1112,41 @@ func (l *list[T]) Blur() tea.Cmd {
938
1112
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
944 }
1118 }
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) {
1127+ item := l.items[i]
1128+ l.indexMap[item.ID()] = i
1129+ }
949 }
1130 }
950
1131
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
954− if ok {
955− l.selectedItem = item.ID()
956− } else {
957− l.selectedItem = ""
958− }
959 } else {
1137 } else {
960− l.selectedItem = ""
1138+ l.selectedItemIdx = -1
961 }
1139 }
1140+ } else if l.selectedItemIdx > inx {
1141+ // Selected item is after the deleted one, shift index down
1142+ l.selectedItemIdx--
962 }
1143 }
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
972 }
1152 }
@@ -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()
995}
1175}
@@ -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()
1003}
1183}
@@ -1009,21 +1189,29 @@ func (l *list[T]) IsFocused() bool {
1009
1189
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) {
1196+ item := l.items[i]
1197+ result = append(result, item)
1198+ }
1199+ }
1200+ return result
1013}
1201}
1014
1202
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 {
1019 return
1206 return
1020 }
1207 }
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 {
1024 return
1211 return
1025 }
1212 }
1026 l.offset += n
1213 l.offset += n
1214+ l.cachedViewDirty = true
1027}
1215}
1028
1216
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
1037 }
1225 }
1226+ l.cachedViewDirty = true
1038}
1227}
1039
1228
1040// MoveDown implements List.
1229// MoveDown implements List.
@@ -1105,14 +1294,26 @@ func (l *list[T]) MoveUp(n int) tea.Cmd {
1105
1294
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())
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++
1110 }
1306 }
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
1115 }
1313 }
1314+ newIndexMap[item.ID()] = 0 // New item is at index 0
1315+ l.indexMap = newIndexMap
1316+
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))
1118 }
1319 }
@@ -1124,13 +1325,13 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
1124 cmds = append(cmds, cmd)
1325 cmds = append(cmds, cmd)
1125 }
1326 }
1126 } else {
1327 } else {
1127− newItem, ok := l.renderedItems.Get(item.ID())
1328+ newItem, ok := l.renderedItems[item.ID()]
1128 if ok {
1329 if ok {
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
1132 }
1333 }
1133− l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
1334+ l.offset = min(l.renderedHeight-1, l.offset+newLines)
1134 }
1335 }
1135 }
1336 }
1136 }
1337 }
@@ -1139,17 +1340,17 @@ func (l *list[T]) PrependItem(item T) tea.Cmd {
1139
1340
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 {
1143− if !ok {
1144 return nil
1344 return nil
1145 }
1345 }
1146
1346
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
1151 }
1351 }
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 {
1160 }
1361 }
1161 }
1362 }
1162 }
1363 }
1163− item, ok := l.items.Get(newIndex)
1364+ if newIndex < 0 || newIndex >= len(l.items) {
1164− if !ok {
1165 return nil
1365 return nil
1166 }
1366 }
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 {
1175
1376
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 {
1179− if !ok {
1180 return nil
1380 return nil
1181 }
1381 }
1182
1382
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
1187 }
1387 }
1188− item, ok := l.items.Get(newIndex)
1388+ if newIndex < 0 || newIndex >= len(l.items) {
1189− if !ok {
1190 return nil
1389 return nil
1191 }
1390 }
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()
1195}
1395}
1196
1396
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) {
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 {
1208 return nil
1400 return nil
1209 }
1401 }
1402+ item := l.items[l.selectedItemIdx]
1210 return &item
1403 return &item
1211}
1404}
1212
1405
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)
1220 }
1413 }
@@ -1226,23 +1419,44 @@ func (l *list[T]) SetItems(items []T) tea.Cmd {
1226
1419
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
1425+ } else {
1426+ l.selectedItemIdx = -1
1427+ }
1230 return l.render()
1428 return l.render()
1231}
1429}
1232
1430
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) {
1441+ continue
1442+ }
1443+
1444+ item := l.items[i]
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))
1244 }
1448 }
1245 }
1449 }
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+ }
1246 cmds = append(cmds, l.render())
1460 cmds = append(cmds, l.render())
1247 return tea.Batch(cmds...)
1461 return tea.Batch(cmds...)
1248}
1462}
@@ -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
1471+ selectedID := ""
1472+ if l.selectedItemIdx >= 0 && l.selectedItemIdx < len(l.items) {
1473+ item := l.items[l.selectedItemIdx]
1474+ selectedID = item.ID()
1475+ }
1256− cmd := l.reset(l.selectedItem)
1476+ cmd := l.reset(selectedID)
1257 return cmd
1477 return cmd
1258 }
1478 }
1259 return nil
1479 return nil
@@ -1261,16 +1481,17 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
1261
1481
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
1271 }
1492 }
1272
1493
1273− l.renderedItems.Del(id)
1494+ delete(l.renderedItems, id)
1274 cmd := l.render()
1495 cmd := l.render()
1275
1496
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()]
1285 if ok {
1506 if ok {
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)
1288 }
1509 }
1289 }
1510 }
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()]
1292 if ok {
1513 if ok {
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)
1295 }
1516 }
1296 }
1517 }
1297 }
1518 }
@@ -1333,13 +1554,10 @@ func (l *list[T]) SelectionClear() {
1333}
1554}
1334
1555
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)
1339− }
1340
1558
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
1343 }
1561 }
1344
1562
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) {
1350 }
1568 }
1351 }
1569 }
1352
1570
1353− if line < 0 || line >= len(lines) {
1571+ if line < 0 || line >= numLines {
1354 return 0, 0
1572 return 0, 0
1355 }
1573 }
1356
1574
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) {
1377}
1595}
1378
1596
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, " ")
1385 }
1604 }
1605+ return cleanLine
1386 }
1606 }
1607+
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
1389 }
1611 }
1390
1612
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
1397 }
1619 }
1398
1620
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
1402 }
1624 }
1403
1625
1404− if strings.TrimSpace(lines[line]) == "" {
1626+ if strings.TrimSpace(getCleanLine(line)) == "" {
1405 return 0, 0, false
1627 return 0, 0, false
1406 }
1628 }
1407
1629
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--
1412 }
1634 }
1413
1635
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++
1418 }
1640 }
1419
1641
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
1424 }
1646 }
1425 if l.offset > 0 {
1647 if l.offset > 0 {
1426 if l.direction == DirectionBackward {
1648 if l.direction == DirectionBackward {
internal/tui/exp/list/list_test.go+32−32
@@ -28,18 +28,18 @@ func TestList(t *testing.T) {
28 execCmd(l, l.Init())
28 execCmd(l, l.Init())
29
29
30 // should select the last item
30 // should select the last item
31− assert.Equal(t, items[0].ID(), l.selectedItem)
31+ assert.Equal(t, 0, l.selectedItemIdx)
32 assert.Equal(t, 0, l.offset)
32 assert.Equal(t, 0, l.offset)
33− require.Equal(t, 5, l.indexMap.Len())
33+ require.Equal(t, 5, len(l.indexMap))
34− require.Equal(t, 5, l.items.Len())
34+ require.Equal(t, 5, len(l.items))
35− require.Equal(t, 5, l.renderedItems.Len())
35+ require.Equal(t, 5, len(l.renderedItems))
36 assert.Equal(t, 5, lipgloss.Height(l.rendered))
36 assert.Equal(t, 5, lipgloss.Height(l.rendered))
37 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
37 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
38 start, end := l.viewPosition()
38 start, end := l.viewPosition()
39 assert.Equal(t, 0, start)
39 assert.Equal(t, 0, start)
40 assert.Equal(t, 4, end)
40 assert.Equal(t, 4, end)
41 for i := range 5 {
41 for i := range 5 {
42− item, ok := l.renderedItems.Get(items[i].ID())
42+ item, ok := l.renderedItems[items[i].ID()]
43 require.True(t, ok)
43 require.True(t, ok)
44 assert.Equal(t, i, item.start)
44 assert.Equal(t, i, item.start)
45 assert.Equal(t, i, item.end)
45 assert.Equal(t, i, item.end)
@@ -58,18 +58,18 @@ func TestList(t *testing.T) {
58 execCmd(l, l.Init())
58 execCmd(l, l.Init())
59
59
60 // should select the last item
60 // should select the last item
61− assert.Equal(t, items[4].ID(), l.selectedItem)
61+ assert.Equal(t, 4, l.selectedItemIdx)
62 assert.Equal(t, 0, l.offset)
62 assert.Equal(t, 0, l.offset)
63− require.Equal(t, 5, l.indexMap.Len())
63+ require.Equal(t, 5, len(l.indexMap))
64− require.Equal(t, 5, l.items.Len())
64+ require.Equal(t, 5, len(l.items))
65− require.Equal(t, 5, l.renderedItems.Len())
65+ require.Equal(t, 5, len(l.renderedItems))
66 assert.Equal(t, 5, lipgloss.Height(l.rendered))
66 assert.Equal(t, 5, lipgloss.Height(l.rendered))
67 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
67 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
68 start, end := l.viewPosition()
68 start, end := l.viewPosition()
69 assert.Equal(t, 0, start)
69 assert.Equal(t, 0, start)
70 assert.Equal(t, 4, end)
70 assert.Equal(t, 4, end)
71 for i := range 5 {
71 for i := range 5 {
72− item, ok := l.renderedItems.Get(items[i].ID())
72+ item, ok := l.renderedItems[items[i].ID()]
73 require.True(t, ok)
73 require.True(t, ok)
74 assert.Equal(t, i, item.start)
74 assert.Equal(t, i, item.start)
75 assert.Equal(t, i, item.end)
75 assert.Equal(t, i, item.end)
@@ -89,18 +89,18 @@ func TestList(t *testing.T) {
89 execCmd(l, l.Init())
89 execCmd(l, l.Init())
90
90
91 // should select the last item
91 // should select the last item
92− assert.Equal(t, items[0].ID(), l.selectedItem)
92+ assert.Equal(t, 0, l.selectedItemIdx)
93 assert.Equal(t, 0, l.offset)
93 assert.Equal(t, 0, l.offset)
94− require.Equal(t, 30, l.indexMap.Len())
94+ require.Equal(t, 30, len(l.indexMap))
95− require.Equal(t, 30, l.items.Len())
95+ require.Equal(t, 30, len(l.items))
96− require.Equal(t, 30, l.renderedItems.Len())
96+ require.Equal(t, 30, len(l.renderedItems))
97 assert.Equal(t, 30, lipgloss.Height(l.rendered))
97 assert.Equal(t, 30, lipgloss.Height(l.rendered))
98 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
98 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
99 start, end := l.viewPosition()
99 start, end := l.viewPosition()
100 assert.Equal(t, 0, start)
100 assert.Equal(t, 0, start)
101 assert.Equal(t, 9, end)
101 assert.Equal(t, 9, end)
102 for i := range 30 {
102 for i := range 30 {
103− item, ok := l.renderedItems.Get(items[i].ID())
103+ item, ok := l.renderedItems[items[i].ID()]
104 require.True(t, ok)
104 require.True(t, ok)
105 assert.Equal(t, i, item.start)
105 assert.Equal(t, i, item.start)
106 assert.Equal(t, i, item.end)
106 assert.Equal(t, i, item.end)
@@ -119,18 +119,18 @@ func TestList(t *testing.T) {
119 execCmd(l, l.Init())
119 execCmd(l, l.Init())
120
120
121 // should select the last item
121 // should select the last item
122− assert.Equal(t, items[29].ID(), l.selectedItem)
122+ assert.Equal(t, 29, l.selectedItemIdx)
123 assert.Equal(t, 0, l.offset)
123 assert.Equal(t, 0, l.offset)
124− require.Equal(t, 30, l.indexMap.Len())
124+ require.Equal(t, 30, len(l.indexMap))
125− require.Equal(t, 30, l.items.Len())
125+ require.Equal(t, 30, len(l.items))
126− require.Equal(t, 30, l.renderedItems.Len())
126+ require.Equal(t, 30, len(l.renderedItems))
127 assert.Equal(t, 30, lipgloss.Height(l.rendered))
127 assert.Equal(t, 30, lipgloss.Height(l.rendered))
128 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
128 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
129 start, end := l.viewPosition()
129 start, end := l.viewPosition()
130 assert.Equal(t, 20, start)
130 assert.Equal(t, 20, start)
131 assert.Equal(t, 29, end)
131 assert.Equal(t, 29, end)
132 for i := range 30 {
132 for i := range 30 {
133− item, ok := l.renderedItems.Get(items[i].ID())
133+ item, ok := l.renderedItems[items[i].ID()]
134 require.True(t, ok)
134 require.True(t, ok)
135 assert.Equal(t, i, item.start)
135 assert.Equal(t, i, item.start)
136 assert.Equal(t, i, item.end)
136 assert.Equal(t, i, item.end)
@@ -152,11 +152,11 @@ func TestList(t *testing.T) {
152 execCmd(l, l.Init())
152 execCmd(l, l.Init())
153
153
154 // should select the last item
154 // should select the last item
155− assert.Equal(t, items[0].ID(), l.selectedItem)
155+ assert.Equal(t, 0, l.selectedItemIdx)
156 assert.Equal(t, 0, l.offset)
156 assert.Equal(t, 0, l.offset)
157− require.Equal(t, 30, l.indexMap.Len())
157+ require.Equal(t, 30, len(l.indexMap))
158− require.Equal(t, 30, l.items.Len())
158+ require.Equal(t, 30, len(l.items))
159− require.Equal(t, 30, l.renderedItems.Len())
159+ require.Equal(t, 30, len(l.renderedItems))
160 expectedLines := 0
160 expectedLines := 0
161 for i := range 30 {
161 for i := range 30 {
162 expectedLines += (i + 1) * 1
162 expectedLines += (i + 1) * 1
@@ -168,7 +168,7 @@ func TestList(t *testing.T) {
168 assert.Equal(t, 9, end)
168 assert.Equal(t, 9, end)
169 currentPosition := 0
169 currentPosition := 0
170 for i := range 30 {
170 for i := range 30 {
171− rItem, ok := l.renderedItems.Get(items[i].ID())
171+ rItem, ok := l.renderedItems[items[i].ID()]
172 require.True(t, ok)
172 require.True(t, ok)
173 assert.Equal(t, currentPosition, rItem.start)
173 assert.Equal(t, currentPosition, rItem.start)
174 assert.Equal(t, currentPosition+i, rItem.end)
174 assert.Equal(t, currentPosition+i, rItem.end)
@@ -190,11 +190,11 @@ func TestList(t *testing.T) {
190 execCmd(l, l.Init())
190 execCmd(l, l.Init())
191
191
192 // should select the last item
192 // should select the last item
193− assert.Equal(t, items[29].ID(), l.selectedItem)
193+ assert.Equal(t, 29, l.selectedItemIdx)
194 assert.Equal(t, 0, l.offset)
194 assert.Equal(t, 0, l.offset)
195− require.Equal(t, 30, l.indexMap.Len())
195+ require.Equal(t, 30, len(l.indexMap))
196− require.Equal(t, 30, l.items.Len())
196+ require.Equal(t, 30, len(l.items))
197− require.Equal(t, 30, l.renderedItems.Len())
197+ require.Equal(t, 30, len(l.renderedItems))
198 expectedLines := 0
198 expectedLines := 0
199 for i := range 30 {
199 for i := range 30 {
200 expectedLines += (i + 1) * 1
200 expectedLines += (i + 1) * 1
@@ -206,7 +206,7 @@ func TestList(t *testing.T) {
206 assert.Equal(t, expectedLines-1, end)
206 assert.Equal(t, expectedLines-1, end)
207 currentPosition := 0
207 currentPosition := 0
208 for i := range 30 {
208 for i := range 30 {
209− rItem, ok := l.renderedItems.Get(items[i].ID())
209+ rItem, ok := l.renderedItems[items[i].ID()]
210 require.True(t, ok)
210 require.True(t, ok)
211 assert.Equal(t, currentPosition, rItem.start)
211 assert.Equal(t, currentPosition, rItem.start)
212 assert.Equal(t, currentPosition+i, rItem.end)
212 assert.Equal(t, currentPosition+i, rItem.end)
@@ -229,7 +229,7 @@ func TestList(t *testing.T) {
229 execCmd(l, l.Init())
229 execCmd(l, l.Init())
230
230
231 // should select the last item
231 // should select the last item
232− assert.Equal(t, items[10].ID(), l.selectedItem)
232+ assert.Equal(t, 10, l.selectedItemIdx)
233
233
234 golden.RequireEqual(t, []byte(l.View()))
234 golden.RequireEqual(t, []byte(l.View()))
235 })
235 })
@@ -247,7 +247,7 @@ func TestList(t *testing.T) {
247 execCmd(l, l.Init())
247 execCmd(l, l.Init())
248
248
249 // should select the last item
249 // should select the last item
250− assert.Equal(t, items[10].ID(), l.selectedItem)
250+ assert.Equal(t, 10, l.selectedItemIdx)
251
251
252 golden.RequireEqual(t, []byte(l.View()))
252 golden.RequireEqual(t, []byte(l.View()))
253 })
253 })