1package list
2
3import (
4 "strings"
5
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/csync"
9 "github.com/charmbracelet/crush/internal/tui/components/anim"
10 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
11 "github.com/charmbracelet/crush/internal/tui/styles"
12 "github.com/charmbracelet/crush/internal/tui/util"
13 "github.com/charmbracelet/lipgloss/v2"
14)
15
16type Item interface {
17 util.Model
18 layout.Sizeable
19 ID() string
20}
21
22type HasAnim interface {
23 Item
24 Spinning() bool
25}
26
27type List[T Item] interface {
28 util.Model
29 layout.Sizeable
30 layout.Focusable
31
32 // Just change state
33 MoveUp(int) tea.Cmd
34 MoveDown(int) tea.Cmd
35 GoToTop() tea.Cmd
36 GoToBottom() tea.Cmd
37 SelectItemAbove() tea.Cmd
38 SelectItemBelow() tea.Cmd
39 SetItems([]T) tea.Cmd
40 SetSelected(string) tea.Cmd
41 SelectedItem() *T
42 Items() []T
43 UpdateItem(string, T) tea.Cmd
44 DeleteItem(string) tea.Cmd
45 PrependItem(T) tea.Cmd
46 AppendItem(T) tea.Cmd
47}
48
49type direction int
50
51const (
52 DirectionForward direction = iota
53 DirectionBackward
54)
55
56const (
57 ItemNotFound = -1
58 ViewportDefaultScrollSize = 2
59)
60
61type renderedItem struct {
62 id string
63 view string
64 height int
65 start int
66 end int
67}
68
69type confOptions struct {
70 width, height int
71 gap int
72 // if you are at the last item and go down it will wrap to the top
73 wrap bool
74 keyMap KeyMap
75 direction direction
76 selectedItem string
77 focused bool
78 resize bool
79 enableMouse bool
80}
81
82type list[T Item] struct {
83 *confOptions
84
85 offset int
86
87 indexMap *csync.Map[string, int]
88 items *csync.Slice[T]
89
90 renderedItems *csync.Map[string, renderedItem]
91
92 rendered string
93
94 movingByItem bool
95}
96
97type ListOption func(*confOptions)
98
99// WithSize sets the size of the list.
100func WithSize(width, height int) ListOption {
101 return func(l *confOptions) {
102 l.width = width
103 l.height = height
104 }
105}
106
107// WithGap sets the gap between items in the list.
108func WithGap(gap int) ListOption {
109 return func(l *confOptions) {
110 l.gap = gap
111 }
112}
113
114// WithDirectionForward sets the direction to forward
115func WithDirectionForward() ListOption {
116 return func(l *confOptions) {
117 l.direction = DirectionForward
118 }
119}
120
121// WithDirectionBackward sets the direction to forward
122func WithDirectionBackward() ListOption {
123 return func(l *confOptions) {
124 l.direction = DirectionBackward
125 }
126}
127
128// WithSelectedItem sets the initially selected item in the list.
129func WithSelectedItem(id string) ListOption {
130 return func(l *confOptions) {
131 l.selectedItem = id
132 }
133}
134
135func WithKeyMap(keyMap KeyMap) ListOption {
136 return func(l *confOptions) {
137 l.keyMap = keyMap
138 }
139}
140
141func WithWrapNavigation() ListOption {
142 return func(l *confOptions) {
143 l.wrap = true
144 }
145}
146
147func WithFocus(focus bool) ListOption {
148 return func(l *confOptions) {
149 l.focused = focus
150 }
151}
152
153func WithResizeByList() ListOption {
154 return func(l *confOptions) {
155 l.resize = true
156 }
157}
158
159func WithEnableMouse() ListOption {
160 return func(l *confOptions) {
161 l.enableMouse = true
162 }
163}
164
165func New[T Item](items []T, opts ...ListOption) List[T] {
166 list := &list[T]{
167 confOptions: &confOptions{
168 direction: DirectionForward,
169 keyMap: DefaultKeyMap(),
170 focused: true,
171 },
172 items: csync.NewSliceFrom(items),
173 indexMap: csync.NewMap[string, int](),
174 renderedItems: csync.NewMap[string, renderedItem](),
175 }
176 for _, opt := range opts {
177 opt(list.confOptions)
178 }
179
180 for inx, item := range items {
181 if i, ok := any(item).(Indexable); ok {
182 i.SetIndex(inx)
183 }
184 list.indexMap.Set(item.ID(), inx)
185 }
186 return list
187}
188
189// Init implements List.
190func (l *list[T]) Init() tea.Cmd {
191 return l.render()
192}
193
194// Update implements List.
195func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
196 switch msg := msg.(type) {
197 case tea.MouseWheelMsg:
198 if l.enableMouse {
199 return l.handleMouseWheel(msg)
200 }
201 return l, nil
202 case anim.StepMsg:
203 var cmds []tea.Cmd
204 for _, item := range l.items.Slice() {
205 if i, ok := any(item).(HasAnim); ok && i.Spinning() {
206 updated, cmd := i.Update(msg)
207 cmds = append(cmds, cmd)
208 if u, ok := updated.(T); ok {
209 cmds = append(cmds, l.UpdateItem(u.ID(), u))
210 }
211 }
212 }
213 return l, tea.Batch(cmds...)
214 case tea.KeyPressMsg:
215 if l.focused {
216 switch {
217 case key.Matches(msg, l.keyMap.Down):
218 return l, l.MoveDown(ViewportDefaultScrollSize)
219 case key.Matches(msg, l.keyMap.Up):
220 return l, l.MoveUp(ViewportDefaultScrollSize)
221 case key.Matches(msg, l.keyMap.DownOneItem):
222 return l, l.SelectItemBelow()
223 case key.Matches(msg, l.keyMap.UpOneItem):
224 return l, l.SelectItemAbove()
225 case key.Matches(msg, l.keyMap.HalfPageDown):
226 return l, l.MoveDown(l.height / 2)
227 case key.Matches(msg, l.keyMap.HalfPageUp):
228 return l, l.MoveUp(l.height / 2)
229 case key.Matches(msg, l.keyMap.PageDown):
230 return l, l.MoveDown(l.height)
231 case key.Matches(msg, l.keyMap.PageUp):
232 return l, l.MoveUp(l.height)
233 case key.Matches(msg, l.keyMap.End):
234 return l, l.GoToBottom()
235 case key.Matches(msg, l.keyMap.Home):
236 return l, l.GoToTop()
237 }
238 }
239 }
240 return l, nil
241}
242
243func (l *list[T]) handleMouseWheel(msg tea.MouseWheelMsg) (tea.Model, tea.Cmd) {
244 var cmd tea.Cmd
245 switch msg.Button {
246 case tea.MouseWheelDown:
247 cmd = l.MoveDown(ViewportDefaultScrollSize)
248 case tea.MouseWheelUp:
249 cmd = l.MoveUp(ViewportDefaultScrollSize)
250 }
251 return l, cmd
252}
253
254// View implements List.
255func (l *list[T]) View() string {
256 if l.height <= 0 || l.width <= 0 {
257 return ""
258 }
259 t := styles.CurrentTheme()
260 view := l.rendered
261 lines := strings.Split(view, "\n")
262
263 start, end := l.viewPosition()
264 viewStart := max(0, start)
265 viewEnd := min(len(lines), end+1)
266 lines = lines[viewStart:viewEnd]
267 if l.resize {
268 return strings.Join(lines, "\n")
269 }
270 return t.S().Base.
271 Height(l.height).
272 Width(l.width).
273 Render(strings.Join(lines, "\n"))
274}
275
276func (l *list[T]) viewPosition() (int, int) {
277 start, end := 0, 0
278 renderedLines := lipgloss.Height(l.rendered) - 1
279 if l.direction == DirectionForward {
280 start = max(0, l.offset)
281 end = min(l.offset+l.height-1, renderedLines)
282 } else {
283 start = max(0, renderedLines-l.offset-l.height+1)
284 end = max(0, renderedLines-l.offset)
285 }
286 return start, end
287}
288
289func (l *list[T]) recalculateItemPositions() {
290 currentContentHeight := 0
291 for _, item := range l.items.Slice() {
292 rItem, ok := l.renderedItems.Get(item.ID())
293 if !ok {
294 continue
295 }
296 rItem.start = currentContentHeight
297 rItem.end = currentContentHeight + rItem.height - 1
298 l.renderedItems.Set(item.ID(), rItem)
299 currentContentHeight = rItem.end + 1 + l.gap
300 }
301}
302
303func (l *list[T]) render() tea.Cmd {
304 if l.width <= 0 || l.height <= 0 || l.items.Len() == 0 {
305 return nil
306 }
307 l.setDefaultSelected()
308
309 var focusChangeCmd tea.Cmd
310 if l.focused {
311 focusChangeCmd = l.focusSelectedItem()
312 } else {
313 focusChangeCmd = l.blurSelectedItem()
314 }
315 // we are not rendering the first time
316 if l.rendered != "" {
317 // rerender everything will mostly hit cache
318 l.rendered, _ = l.renderIterator(0, false, "")
319 if l.direction == DirectionBackward {
320 l.recalculateItemPositions()
321 }
322 // in the end scroll to the selected item
323 if l.focused {
324 l.scrollToSelection()
325 }
326 return focusChangeCmd
327 }
328 rendered, finishIndex := l.renderIterator(0, true, "")
329 l.rendered = rendered
330
331 // recalculate for the initial items
332 if l.direction == DirectionBackward {
333 l.recalculateItemPositions()
334 }
335 renderCmd := func() tea.Msg {
336 l.offset = 0
337 // render the rest
338 l.rendered, _ = l.renderIterator(finishIndex, false, l.rendered)
339 // needed for backwards
340 if l.direction == DirectionBackward {
341 l.recalculateItemPositions()
342 }
343 // in the end scroll to the selected item
344 if l.focused {
345 l.scrollToSelection()
346 }
347
348 return nil
349 }
350 return tea.Batch(focusChangeCmd, renderCmd)
351}
352
353func (l *list[T]) setDefaultSelected() {
354 if l.selectedItem == "" {
355 if l.direction == DirectionForward {
356 l.selectFirstItem()
357 } else {
358 l.selectLastItem()
359 }
360 }
361}
362
363func (l *list[T]) scrollToSelection() {
364 rItem, ok := l.renderedItems.Get(l.selectedItem)
365 if !ok {
366 l.selectedItem = ""
367 l.setDefaultSelected()
368 return
369 }
370
371 start, end := l.viewPosition()
372 // item bigger or equal to the viewport do nothing
373 if rItem.start <= start && rItem.end >= end {
374 return
375 }
376 // if we are moving by item we want to move the offset so that the
377 // whole item is visible not just portions of it
378 if l.movingByItem {
379 if rItem.start >= start && rItem.end <= end {
380 return
381 }
382 defer func() { l.movingByItem = false }()
383 } else {
384 // item already in view do nothing
385 if rItem.start >= start && rItem.start <= end {
386 return
387 }
388 if rItem.end >= start && rItem.end <= end {
389 return
390 }
391 }
392
393 if rItem.height >= l.height {
394 if l.direction == DirectionForward {
395 l.offset = rItem.start
396 } else {
397 l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
398 }
399 return
400 }
401
402 renderedLines := lipgloss.Height(l.rendered) - 1
403
404 // If item is above the viewport, make it the first item
405 if rItem.start < start {
406 if l.direction == DirectionForward {
407 l.offset = rItem.start
408 } else {
409 l.offset = max(0, renderedLines-rItem.start-l.height+1)
410 }
411 } else if rItem.end > end {
412 // If item is below the viewport, make it the last item
413 if l.direction == DirectionForward {
414 l.offset = max(0, rItem.end-l.height+1)
415 } else {
416 l.offset = max(0, renderedLines-rItem.end)
417 }
418 }
419}
420
421func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
422 rItem, ok := l.renderedItems.Get(l.selectedItem)
423 if !ok {
424 return nil
425 }
426 start, end := l.viewPosition()
427 // item bigger than the viewport do nothing
428 if rItem.start <= start && rItem.end >= end {
429 return nil
430 }
431 // item already in view do nothing
432 if rItem.start >= start && rItem.end <= end {
433 return nil
434 }
435
436 itemMiddle := rItem.start + rItem.height/2
437
438 if itemMiddle < start {
439 // select the first item in the viewport
440 // the item is most likely an item coming after this item
441 inx, ok := l.indexMap.Get(rItem.id)
442 if !ok {
443 return nil
444 }
445 for {
446 inx = l.firstSelectableItemBelow(inx)
447 if inx == ItemNotFound {
448 return nil
449 }
450 item, ok := l.items.Get(inx)
451 if !ok {
452 continue
453 }
454 renderedItem, ok := l.renderedItems.Get(item.ID())
455 if !ok {
456 continue
457 }
458
459 // If the item is bigger than the viewport, select it
460 if renderedItem.start <= start && renderedItem.end >= end {
461 l.selectedItem = renderedItem.id
462 return l.render()
463 }
464 // item is in the view
465 if renderedItem.start >= start && renderedItem.start <= end {
466 l.selectedItem = renderedItem.id
467 return l.render()
468 }
469 }
470 } else if itemMiddle > end {
471 // select the first item in the viewport
472 // the item is most likely an item coming after this item
473 inx, ok := l.indexMap.Get(rItem.id)
474 if !ok {
475 return nil
476 }
477 for {
478 inx = l.firstSelectableItemAbove(inx)
479 if inx == ItemNotFound {
480 return nil
481 }
482 item, ok := l.items.Get(inx)
483 if !ok {
484 continue
485 }
486 renderedItem, ok := l.renderedItems.Get(item.ID())
487 if !ok {
488 continue
489 }
490
491 // If the item is bigger than the viewport, select it
492 if renderedItem.start <= start && renderedItem.end >= end {
493 l.selectedItem = renderedItem.id
494 return l.render()
495 }
496 // item is in the view
497 if renderedItem.end >= start && renderedItem.end <= end {
498 l.selectedItem = renderedItem.id
499 return l.render()
500 }
501 }
502 }
503 return nil
504}
505
506func (l *list[T]) selectFirstItem() {
507 inx := l.firstSelectableItemBelow(-1)
508 if inx != ItemNotFound {
509 item, ok := l.items.Get(inx)
510 if ok {
511 l.selectedItem = item.ID()
512 }
513 }
514}
515
516func (l *list[T]) selectLastItem() {
517 inx := l.firstSelectableItemAbove(l.items.Len())
518 if inx != ItemNotFound {
519 item, ok := l.items.Get(inx)
520 if ok {
521 l.selectedItem = item.ID()
522 }
523 }
524}
525
526func (l *list[T]) firstSelectableItemAbove(inx int) int {
527 for i := inx - 1; i >= 0; i-- {
528 item, ok := l.items.Get(i)
529 if !ok {
530 continue
531 }
532 if _, ok := any(item).(layout.Focusable); ok {
533 return i
534 }
535 }
536 if inx == 0 && l.wrap {
537 return l.firstSelectableItemAbove(l.items.Len())
538 }
539 return ItemNotFound
540}
541
542func (l *list[T]) firstSelectableItemBelow(inx int) int {
543 itemsLen := l.items.Len()
544 for i := inx + 1; i < itemsLen; i++ {
545 item, ok := l.items.Get(i)
546 if !ok {
547 continue
548 }
549 if _, ok := any(item).(layout.Focusable); ok {
550 return i
551 }
552 }
553 if inx == itemsLen-1 && l.wrap {
554 return l.firstSelectableItemBelow(-1)
555 }
556 return ItemNotFound
557}
558
559func (l *list[T]) focusSelectedItem() tea.Cmd {
560 if l.selectedItem == "" || !l.focused {
561 return nil
562 }
563 var cmds []tea.Cmd
564 for _, item := range l.items.Slice() {
565 if f, ok := any(item).(layout.Focusable); ok {
566 if item.ID() == l.selectedItem && !f.IsFocused() {
567 cmds = append(cmds, f.Focus())
568 l.renderedItems.Del(item.ID())
569 } else if item.ID() != l.selectedItem && f.IsFocused() {
570 cmds = append(cmds, f.Blur())
571 l.renderedItems.Del(item.ID())
572 }
573 }
574 }
575 return tea.Batch(cmds...)
576}
577
578func (l *list[T]) blurSelectedItem() tea.Cmd {
579 if l.selectedItem == "" || l.focused {
580 return nil
581 }
582 var cmds []tea.Cmd
583 for _, item := range l.items.Slice() {
584 if f, ok := any(item).(layout.Focusable); ok {
585 if item.ID() == l.selectedItem && f.IsFocused() {
586 cmds = append(cmds, f.Blur())
587 l.renderedItems.Del(item.ID())
588 }
589 }
590 }
591 return tea.Batch(cmds...)
592}
593
594// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
595// returns the last index and the rendered content so far
596// we pass the rendered content around and don't use l.rendered to prevent jumping of the content
597func (l *list[T]) renderIterator(startInx int, limitHeight bool, rendered string) (string, int) {
598 currentContentHeight := lipgloss.Height(rendered) - 1
599 itemsLen := l.items.Len()
600 for i := startInx; i < itemsLen; i++ {
601 if currentContentHeight >= l.height && limitHeight {
602 return rendered, i
603 }
604 // cool way to go through the list in both directions
605 inx := i
606
607 if l.direction != DirectionForward {
608 inx = (itemsLen - 1) - i
609 }
610
611 item, ok := l.items.Get(inx)
612 if !ok {
613 continue
614 }
615 var rItem renderedItem
616 if cache, ok := l.renderedItems.Get(item.ID()); ok {
617 rItem = cache
618 } else {
619 rItem = l.renderItem(item)
620 rItem.start = currentContentHeight
621 rItem.end = currentContentHeight + rItem.height - 1
622 l.renderedItems.Set(item.ID(), rItem)
623 }
624 gap := l.gap + 1
625 if inx == itemsLen-1 {
626 gap = 0
627 }
628
629 if l.direction == DirectionForward {
630 rendered += rItem.view + strings.Repeat("\n", gap)
631 } else {
632 rendered = rItem.view + strings.Repeat("\n", gap) + rendered
633 }
634 currentContentHeight = rItem.end + 1 + l.gap
635 }
636 return rendered, itemsLen
637}
638
639func (l *list[T]) renderItem(item Item) renderedItem {
640 view := item.View()
641 return renderedItem{
642 id: item.ID(),
643 view: view,
644 height: lipgloss.Height(view),
645 }
646}
647
648// AppendItem implements List.
649func (l *list[T]) AppendItem(item T) tea.Cmd {
650 var cmds []tea.Cmd
651 cmd := item.Init()
652 if cmd != nil {
653 cmds = append(cmds, cmd)
654 }
655
656 l.items.Append(item)
657 l.indexMap = csync.NewMap[string, int]()
658 for inx, item := range l.items.Slice() {
659 l.indexMap.Set(item.ID(), inx)
660 }
661 if l.width > 0 && l.height > 0 {
662 cmd = item.SetSize(l.width, l.height)
663 if cmd != nil {
664 cmds = append(cmds, cmd)
665 }
666 }
667 cmd = l.render()
668 if cmd != nil {
669 cmds = append(cmds, cmd)
670 }
671 if l.direction == DirectionBackward {
672 if l.offset == 0 {
673 cmd = l.GoToBottom()
674 if cmd != nil {
675 cmds = append(cmds, cmd)
676 }
677 } else {
678 newItem, ok := l.renderedItems.Get(item.ID())
679 if ok {
680 newLines := newItem.height
681 if l.items.Len() > 1 {
682 newLines += l.gap
683 }
684 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
685 }
686 }
687 }
688 return tea.Sequence(cmds...)
689}
690
691// Blur implements List.
692func (l *list[T]) Blur() tea.Cmd {
693 l.focused = false
694 return l.render()
695}
696
697// DeleteItem implements List.
698func (l *list[T]) DeleteItem(id string) tea.Cmd {
699 inx, ok := l.indexMap.Get(id)
700 if !ok {
701 return nil
702 }
703 l.items.Delete(inx)
704 l.renderedItems.Del(id)
705 for inx, item := range l.items.Slice() {
706 l.indexMap.Set(item.ID(), inx)
707 }
708
709 if l.selectedItem == id {
710 if inx > 0 {
711 item, ok := l.items.Get(inx - 1)
712 if ok {
713 l.selectedItem = item.ID()
714 } else {
715 l.selectedItem = ""
716 }
717 } else {
718 l.selectedItem = ""
719 }
720 }
721 cmd := l.render()
722 if l.rendered != "" {
723 renderedHeight := lipgloss.Height(l.rendered)
724 if renderedHeight <= l.height {
725 l.offset = 0
726 } else {
727 maxOffset := renderedHeight - l.height
728 if l.offset > maxOffset {
729 l.offset = maxOffset
730 }
731 }
732 }
733 return cmd
734}
735
736// Focus implements List.
737func (l *list[T]) Focus() tea.Cmd {
738 l.focused = true
739 return l.render()
740}
741
742// GetSize implements List.
743func (l *list[T]) GetSize() (int, int) {
744 return l.width, l.height
745}
746
747// GoToBottom implements List.
748func (l *list[T]) GoToBottom() tea.Cmd {
749 if l.offset != 0 {
750 l.selectedItem = ""
751 }
752 l.offset = 0
753 l.direction = DirectionBackward
754 return l.render()
755}
756
757// GoToTop implements List.
758func (l *list[T]) GoToTop() tea.Cmd {
759 if l.offset != 0 {
760 l.selectedItem = ""
761 }
762 l.offset = 0
763 l.direction = DirectionForward
764 return l.render()
765}
766
767// IsFocused implements List.
768func (l *list[T]) IsFocused() bool {
769 return l.focused
770}
771
772// Items implements List.
773func (l *list[T]) Items() []T {
774 return l.items.Slice()
775}
776
777func (l *list[T]) incrementOffset(n int) {
778 renderedHeight := lipgloss.Height(l.rendered)
779 // no need for offset
780 if renderedHeight <= l.height {
781 return
782 }
783 maxOffset := renderedHeight - l.height
784 n = min(n, maxOffset-l.offset)
785 if n <= 0 {
786 return
787 }
788 l.offset += n
789}
790
791func (l *list[T]) decrementOffset(n int) {
792 n = min(n, l.offset)
793 if n <= 0 {
794 return
795 }
796 l.offset -= n
797 if l.offset < 0 {
798 l.offset = 0
799 }
800}
801
802// MoveDown implements List.
803func (l *list[T]) MoveDown(n int) tea.Cmd {
804 if l.direction == DirectionForward {
805 l.incrementOffset(n)
806 } else {
807 l.decrementOffset(n)
808 }
809 return l.changeSelectionWhenScrolling()
810}
811
812// MoveUp implements List.
813func (l *list[T]) MoveUp(n int) tea.Cmd {
814 if l.direction == DirectionForward {
815 l.decrementOffset(n)
816 } else {
817 l.incrementOffset(n)
818 }
819 return l.changeSelectionWhenScrolling()
820}
821
822// PrependItem implements List.
823func (l *list[T]) PrependItem(item T) tea.Cmd {
824 cmds := []tea.Cmd{
825 item.Init(),
826 }
827 l.items.Prepend(item)
828 l.indexMap = csync.NewMap[string, int]()
829 for inx, item := range l.items.Slice() {
830 l.indexMap.Set(item.ID(), inx)
831 }
832 if l.width > 0 && l.height > 0 {
833 cmds = append(cmds, item.SetSize(l.width, l.height))
834 }
835 cmds = append(cmds, l.render())
836 if l.direction == DirectionForward {
837 if l.offset == 0 {
838 cmd := l.GoToTop()
839 if cmd != nil {
840 cmds = append(cmds, cmd)
841 }
842 } else {
843 newItem, ok := l.renderedItems.Get(item.ID())
844 if ok {
845 newLines := newItem.height
846 if l.items.Len() > 1 {
847 newLines += l.gap
848 }
849 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
850 }
851 }
852 }
853 return tea.Batch(cmds...)
854}
855
856// SelectItemAbove implements List.
857func (l *list[T]) SelectItemAbove() tea.Cmd {
858 inx, ok := l.indexMap.Get(l.selectedItem)
859 if !ok {
860 return nil
861 }
862
863 newIndex := l.firstSelectableItemAbove(inx)
864 if newIndex == ItemNotFound {
865 // no item above
866 return nil
867 }
868 var cmds []tea.Cmd
869 if newIndex == 1 {
870 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
871 if peakAboveIndex == ItemNotFound {
872 // this means there is a section above move to the top
873 cmd := l.GoToTop()
874 if cmd != nil {
875 cmds = append(cmds, cmd)
876 }
877 }
878 }
879 item, ok := l.items.Get(newIndex)
880 if !ok {
881 return nil
882 }
883 l.selectedItem = item.ID()
884 l.movingByItem = true
885 renderCmd := l.render()
886 if renderCmd != nil {
887 cmds = append(cmds, renderCmd)
888 }
889 return tea.Sequence(cmds...)
890}
891
892// SelectItemBelow implements List.
893func (l *list[T]) SelectItemBelow() tea.Cmd {
894 inx, ok := l.indexMap.Get(l.selectedItem)
895 if !ok {
896 return nil
897 }
898
899 newIndex := l.firstSelectableItemBelow(inx)
900 if newIndex == ItemNotFound {
901 // no item above
902 return nil
903 }
904 item, ok := l.items.Get(newIndex)
905 if !ok {
906 return nil
907 }
908 l.selectedItem = item.ID()
909 l.movingByItem = true
910 return l.render()
911}
912
913// SelectedItem implements List.
914func (l *list[T]) SelectedItem() *T {
915 inx, ok := l.indexMap.Get(l.selectedItem)
916 if !ok {
917 return nil
918 }
919 if inx > l.items.Len()-1 {
920 return nil
921 }
922 item, ok := l.items.Get(inx)
923 if !ok {
924 return nil
925 }
926 return &item
927}
928
929// SetItems implements List.
930func (l *list[T]) SetItems(items []T) tea.Cmd {
931 l.items.SetSlice(items)
932 var cmds []tea.Cmd
933 for inx, item := range l.items.Slice() {
934 if i, ok := any(item).(Indexable); ok {
935 i.SetIndex(inx)
936 }
937 cmds = append(cmds, item.Init())
938 }
939 cmds = append(cmds, l.reset(""))
940 return tea.Batch(cmds...)
941}
942
943// SetSelected implements List.
944func (l *list[T]) SetSelected(id string) tea.Cmd {
945 l.selectedItem = id
946 return l.render()
947}
948
949func (l *list[T]) reset(selectedItem string) tea.Cmd {
950 var cmds []tea.Cmd
951 l.rendered = ""
952 l.offset = 0
953 l.selectedItem = selectedItem
954 l.indexMap = csync.NewMap[string, int]()
955 l.renderedItems = csync.NewMap[string, renderedItem]()
956 for inx, item := range l.items.Slice() {
957 l.indexMap.Set(item.ID(), inx)
958 if l.width > 0 && l.height > 0 {
959 cmds = append(cmds, item.SetSize(l.width, l.height))
960 }
961 }
962 cmds = append(cmds, l.render())
963 return tea.Batch(cmds...)
964}
965
966// SetSize implements List.
967func (l *list[T]) SetSize(width int, height int) tea.Cmd {
968 oldWidth := l.width
969 l.width = width
970 l.height = height
971 if oldWidth != width {
972 cmd := l.reset(l.selectedItem)
973 return cmd
974 }
975 return nil
976}
977
978// UpdateItem implements List.
979func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
980 var cmds []tea.Cmd
981 if inx, ok := l.indexMap.Get(id); ok {
982 l.items.Set(inx, item)
983 oldItem, hasOldItem := l.renderedItems.Get(id)
984 oldPosition := l.offset
985 if l.direction == DirectionBackward {
986 oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
987 }
988
989 l.renderedItems.Del(id)
990 cmd := l.render()
991
992 // need to check for nil because of sequence not handling nil
993 if cmd != nil {
994 cmds = append(cmds, cmd)
995 }
996 if hasOldItem && l.direction == DirectionBackward {
997 // if we are the last item and there is no offset
998 // make sure to go to the bottom
999 if inx == l.items.Len()-1 && l.offset == 0 {
1000 cmd = l.GoToBottom()
1001 if cmd != nil {
1002 cmds = append(cmds, cmd)
1003 }
1004
1005 // if the item is at least partially below the viewport
1006 } else if oldPosition < oldItem.end {
1007 newItem, ok := l.renderedItems.Get(item.ID())
1008 if ok {
1009 newLines := newItem.height - oldItem.height
1010 l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1011 }
1012 }
1013 } else if hasOldItem && l.offset > oldItem.start {
1014 newItem, ok := l.renderedItems.Get(item.ID())
1015 if ok {
1016 newLines := newItem.height - oldItem.height
1017 l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1018 }
1019 }
1020 }
1021 return tea.Sequence(cmds...)
1022}