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 l.offset = 0
750 l.selectedItem = ""
751 l.direction = DirectionBackward
752 return l.render()
753}
754
755// GoToTop implements List.
756func (l *list[T]) GoToTop() tea.Cmd {
757 l.offset = 0
758 l.selectedItem = ""
759 l.direction = DirectionForward
760 return l.render()
761}
762
763// IsFocused implements List.
764func (l *list[T]) IsFocused() bool {
765 return l.focused
766}
767
768// Items implements List.
769func (l *list[T]) Items() []T {
770 return l.items.Slice()
771}
772
773func (l *list[T]) incrementOffset(n int) {
774 renderedHeight := lipgloss.Height(l.rendered)
775 // no need for offset
776 if renderedHeight <= l.height {
777 return
778 }
779 maxOffset := renderedHeight - l.height
780 n = min(n, maxOffset-l.offset)
781 if n <= 0 {
782 return
783 }
784 l.offset += n
785}
786
787func (l *list[T]) decrementOffset(n int) {
788 n = min(n, l.offset)
789 if n <= 0 {
790 return
791 }
792 l.offset -= n
793 if l.offset < 0 {
794 l.offset = 0
795 }
796}
797
798// MoveDown implements List.
799func (l *list[T]) MoveDown(n int) tea.Cmd {
800 if l.direction == DirectionForward {
801 l.incrementOffset(n)
802 } else {
803 l.decrementOffset(n)
804 }
805 return l.changeSelectionWhenScrolling()
806}
807
808// MoveUp implements List.
809func (l *list[T]) MoveUp(n int) tea.Cmd {
810 if l.direction == DirectionForward {
811 l.decrementOffset(n)
812 } else {
813 l.incrementOffset(n)
814 }
815 return l.changeSelectionWhenScrolling()
816}
817
818// PrependItem implements List.
819func (l *list[T]) PrependItem(item T) tea.Cmd {
820 cmds := []tea.Cmd{
821 item.Init(),
822 }
823 l.items.Prepend(item)
824 l.indexMap = csync.NewMap[string, int]()
825 for inx, item := range l.items.Slice() {
826 l.indexMap.Set(item.ID(), inx)
827 }
828 if l.width > 0 && l.height > 0 {
829 cmds = append(cmds, item.SetSize(l.width, l.height))
830 }
831 cmds = append(cmds, l.render())
832 if l.direction == DirectionForward {
833 if l.offset == 0 {
834 cmd := l.GoToTop()
835 if cmd != nil {
836 cmds = append(cmds, cmd)
837 }
838 } else {
839 newItem, ok := l.renderedItems.Get(item.ID())
840 if ok {
841 newLines := newItem.height
842 if l.items.Len() > 1 {
843 newLines += l.gap
844 }
845 l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
846 }
847 }
848 }
849 return tea.Batch(cmds...)
850}
851
852// SelectItemAbove implements List.
853func (l *list[T]) SelectItemAbove() tea.Cmd {
854 inx, ok := l.indexMap.Get(l.selectedItem)
855 if !ok {
856 return nil
857 }
858
859 newIndex := l.firstSelectableItemAbove(inx)
860 if newIndex == ItemNotFound {
861 // no item above
862 return nil
863 }
864 var cmds []tea.Cmd
865 if newIndex == 1 {
866 peakAboveIndex := l.firstSelectableItemAbove(newIndex)
867 if peakAboveIndex == ItemNotFound {
868 // this means there is a section above move to the top
869 cmd := l.GoToTop()
870 if cmd != nil {
871 cmds = append(cmds, cmd)
872 }
873 }
874 }
875 item, ok := l.items.Get(newIndex)
876 if !ok {
877 return nil
878 }
879 l.selectedItem = item.ID()
880 l.movingByItem = true
881 renderCmd := l.render()
882 if renderCmd != nil {
883 cmds = append(cmds, renderCmd)
884 }
885 return tea.Sequence(cmds...)
886}
887
888// SelectItemBelow implements List.
889func (l *list[T]) SelectItemBelow() tea.Cmd {
890 inx, ok := l.indexMap.Get(l.selectedItem)
891 if !ok {
892 return nil
893 }
894
895 newIndex := l.firstSelectableItemBelow(inx)
896 if newIndex == ItemNotFound {
897 // no item above
898 return nil
899 }
900 item, ok := l.items.Get(newIndex)
901 if !ok {
902 return nil
903 }
904 l.selectedItem = item.ID()
905 l.movingByItem = true
906 return l.render()
907}
908
909// SelectedItem implements List.
910func (l *list[T]) SelectedItem() *T {
911 inx, ok := l.indexMap.Get(l.selectedItem)
912 if !ok {
913 return nil
914 }
915 if inx > l.items.Len()-1 {
916 return nil
917 }
918 item, ok := l.items.Get(inx)
919 if !ok {
920 return nil
921 }
922 return &item
923}
924
925// SetItems implements List.
926func (l *list[T]) SetItems(items []T) tea.Cmd {
927 l.items.SetSlice(items)
928 var cmds []tea.Cmd
929 for inx, item := range l.items.Slice() {
930 if i, ok := any(item).(Indexable); ok {
931 i.SetIndex(inx)
932 }
933 cmds = append(cmds, item.Init())
934 }
935 cmds = append(cmds, l.reset(""))
936 return tea.Batch(cmds...)
937}
938
939// SetSelected implements List.
940func (l *list[T]) SetSelected(id string) tea.Cmd {
941 l.selectedItem = id
942 return l.render()
943}
944
945func (l *list[T]) reset(selectedItem string) tea.Cmd {
946 var cmds []tea.Cmd
947 l.rendered = ""
948 l.offset = 0
949 l.selectedItem = selectedItem
950 l.indexMap = csync.NewMap[string, int]()
951 l.renderedItems = csync.NewMap[string, renderedItem]()
952 for inx, item := range l.items.Slice() {
953 l.indexMap.Set(item.ID(), inx)
954 if l.width > 0 && l.height > 0 {
955 cmds = append(cmds, item.SetSize(l.width, l.height))
956 }
957 }
958 cmds = append(cmds, l.render())
959 return tea.Batch(cmds...)
960}
961
962// SetSize implements List.
963func (l *list[T]) SetSize(width int, height int) tea.Cmd {
964 oldWidth := l.width
965 l.width = width
966 l.height = height
967 if oldWidth != width {
968 cmd := l.reset(l.selectedItem)
969 return cmd
970 }
971 return nil
972}
973
974// UpdateItem implements List.
975func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
976 var cmds []tea.Cmd
977 if inx, ok := l.indexMap.Get(id); ok {
978 l.items.Set(inx, item)
979 oldItem, hasOldItem := l.renderedItems.Get(id)
980 oldPosition := l.offset
981 if l.direction == DirectionBackward {
982 oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
983 }
984
985 l.renderedItems.Del(id)
986 cmd := l.render()
987
988 // need to check for nil because of sequence not handling nil
989 if cmd != nil {
990 cmds = append(cmds, cmd)
991 }
992 if hasOldItem && l.direction == DirectionBackward {
993 // if we are the last item and there is no offset
994 // make sure to go to the bottom
995 if oldPosition < oldItem.end {
996 newItem, ok := l.renderedItems.Get(item.ID())
997 if ok {
998 newLines := newItem.height - oldItem.height
999 l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1000 }
1001 }
1002 } else if hasOldItem && l.offset > oldItem.start {
1003 newItem, ok := l.renderedItems.Get(item.ID())
1004 if ok {
1005 newLines := newItem.height - oldItem.height
1006 l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
1007 }
1008 }
1009 }
1010 return tea.Sequence(cmds...)
1011}