list.go

  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}