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