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