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