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