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