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