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