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