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