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