1package list
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  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 List interface {
 20	util.Model
 21	layout.Sizeable
 22	layout.Focusable
 23	SetItems(items []Item) tea.Cmd
 24}
 25
 26type direction int
 27
 28const (
 29	Forward direction = iota
 30	Backward
 31)
 32
 33const (
 34	NotFound = -1
 35)
 36
 37type renderedItem struct {
 38	id     string
 39	view   string
 40	height int
 41}
 42
 43type list struct {
 44	width, height int
 45	offset        int
 46	gap           int
 47	direction     direction
 48	selectedItem  string
 49	focused       bool
 50
 51	items         []Item
 52	renderedItems []renderedItem
 53	rendered      string
 54	isReady       bool
 55}
 56
 57type listOption func(*list)
 58
 59// WithItems sets the initial items for the list.
 60func WithItems(items ...Item) listOption {
 61	return func(l *list) {
 62		l.items = items
 63	}
 64}
 65
 66// WithSize sets the size of the list.
 67func WithSize(width, height int) listOption {
 68	return func(l *list) {
 69		l.width = width
 70		l.height = height
 71	}
 72}
 73
 74// WithGap sets the gap between items in the list.
 75func WithGap(gap int) listOption {
 76	return func(l *list) {
 77		l.gap = gap
 78	}
 79}
 80
 81// WithDirection sets the direction of the list.
 82func WithDirection(dir direction) listOption {
 83	return func(l *list) {
 84		l.direction = dir
 85	}
 86}
 87
 88// WithSelectedItem sets the initially selected item in the list.
 89func WithSelectedItem(id string) listOption {
 90	return func(l *list) {
 91		l.selectedItem = id
 92	}
 93}
 94
 95func New(opts ...listOption) List {
 96	list := &list{
 97		items:     make([]Item, 0),
 98		direction: Forward,
 99	}
100	for _, opt := range opts {
101		opt(list)
102	}
103	return list
104}
105
106// Init implements List.
107func (l *list) Init() tea.Cmd {
108	if l.height <= 0 || l.width <= 0 {
109		return nil
110	}
111	if len(l.items) == 0 {
112		return nil
113	}
114	var cmds []tea.Cmd
115	for _, item := range l.items {
116		cmd := item.Init()
117		cmds = append(cmds, cmd)
118	}
119	cmds = append(cmds, l.renderItems())
120	return tea.Batch(cmds...)
121}
122
123// Update implements List.
124func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
125	return l, nil
126}
127
128// View implements List.
129func (l *list) View() string {
130	if l.height <= 0 || l.width <= 0 {
131		return ""
132	}
133	view := l.rendered
134	lines := strings.Split(view, "\n")
135
136	start, end := l.viewPosition()
137	lines = lines[start : end+1]
138	return strings.Join(lines, "\n")
139}
140
141func (l *list) viewPosition() (int, int) {
142	start, end := 0, 0
143	renderedLines := lipgloss.Height(l.rendered) - 1
144	if l.direction == Forward {
145		start = max(0, l.offset)
146		end = min(l.offset+l.listHeight()-1, renderedLines)
147	} else {
148		start = max(0, renderedLines-l.offset-l.listHeight()+1)
149		end = max(0, renderedLines-l.offset)
150	}
151	return start, end
152}
153
154func (l *list) renderItem(item Item) renderedItem {
155	view := item.View()
156	return renderedItem{
157		id:     item.ID(),
158		view:   view,
159		height: lipgloss.Height(view),
160	}
161}
162
163func (l *list) renderView() {
164	var sb strings.Builder
165	for i, rendered := range l.renderedItems {
166		sb.WriteString(rendered.view)
167		if i < len(l.renderedItems)-1 {
168			sb.WriteString(strings.Repeat("\n", l.gap+1))
169		}
170	}
171	l.rendered = sb.String()
172}
173
174func (l *list) incrementOffset(n int) {
175	if !l.isReady {
176		return
177	}
178	renderedHeight := lipgloss.Height(l.rendered)
179	// no need for offset
180	if renderedHeight <= l.listHeight() {
181		return
182	}
183	maxOffset := renderedHeight - l.listHeight()
184	n = min(n, maxOffset-l.offset)
185	if n <= 0 {
186		return
187	}
188	l.offset += n
189}
190
191func (l *list) decrementOffset(n int) {
192	if !l.isReady {
193		return
194	}
195	n = min(n, l.offset)
196	if n <= 0 {
197		return
198	}
199	l.offset -= n
200	if l.offset < 0 {
201		l.offset = 0
202	}
203}
204
205// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
206func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
207	var cmds []tea.Cmd
208	start, end := l.viewPosition()
209	currentPosition := 0
210	itemWithinView := NotFound
211	needsMove := false
212
213	for i, item := range l.items {
214		rendered := l.renderedItems[i]
215		itemStart := currentPosition
216		// we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
217		itemEnd := itemStart + rendered.height - 1
218		if itemStart >= start && itemEnd <= end {
219			itemWithinView = i
220		}
221		if item.ID() == l.selectedItem {
222			// item is completely above the viewport
223			if itemStart < start && itemEnd < start {
224				needsMove = true
225			}
226			// item is completely below the viewport
227			if itemStart > end && itemEnd > end {
228				needsMove = true
229			}
230			if needsMove {
231				if focusable, ok := item.(layout.Focusable); ok {
232					cmds = append(cmds, focusable.Blur())
233				}
234				l.renderedItems[i] = l.renderItem(item)
235			} else {
236				return nil
237			}
238		}
239		if itemWithinView != NotFound && needsMove {
240			newSelection := l.items[itemWithinView]
241			l.selectedItem = newSelection.ID()
242			if focusable, ok := newSelection.(layout.Focusable); ok {
243				cmds = append(cmds, focusable.Focus())
244			}
245			l.renderedItems[itemWithinView] = l.renderItem(newSelection)
246			break
247		}
248		currentPosition += rendered.height + l.gap
249	}
250	l.renderView()
251	return tea.Batch(cmds...)
252}
253
254func (l *list) MoveUp(n int) tea.Cmd {
255	if l.direction == Forward {
256		l.decrementOffset(n)
257	} else {
258		l.incrementOffset(n)
259	}
260	return l.changeSelectedWhenNotVisible()
261}
262
263func (l *list) MoveDown(n int) tea.Cmd {
264	if l.direction == Forward {
265		l.incrementOffset(n)
266	} else {
267		l.decrementOffset(n)
268	}
269	return l.changeSelectedWhenNotVisible()
270}
271
272func (l *list) firstSelectableItemBefore(inx int) int {
273	for i := inx - 1; i >= 0; i-- {
274		if _, ok := l.items[i].(layout.Focusable); ok {
275			return i
276		}
277	}
278	return NotFound
279}
280
281func (l *list) firstSelectableItemAfter(inx int) int {
282	for i := inx + 1; i < len(l.items); i++ {
283		if _, ok := l.items[i].(layout.Focusable); ok {
284			return i
285		}
286	}
287	return NotFound
288}
289
290func (l *list) moveToSelected() {
291	if l.selectedItem == "" || !l.isReady {
292		return
293	}
294	currentPosition := 0
295	start, end := l.viewPosition()
296	for _, item := range l.renderedItems {
297		if item.id == l.selectedItem {
298			if start <= currentPosition && (currentPosition+item.height) <= end {
299				return
300			}
301			// we need to go up
302			if currentPosition < start {
303				l.MoveUp(start - currentPosition)
304			}
305			// we need to go down
306			if currentPosition > end {
307				l.MoveDown(currentPosition - end)
308			}
309		}
310		currentPosition += item.height + l.gap
311	}
312}
313
314func (l *list) SelectItemAbove() tea.Cmd {
315	if !l.isReady {
316		return nil
317	}
318	var cmds []tea.Cmd
319	for i, item := range l.items {
320		if l.selectedItem == item.ID() {
321			inx := l.firstSelectableItemBefore(i)
322			if inx == NotFound {
323				// no item above
324				return nil
325			}
326			// blur the current item
327			if focusable, ok := item.(layout.Focusable); ok {
328				cmds = append(cmds, focusable.Blur())
329			}
330			// rerender the item
331			l.renderedItems[i] = l.renderItem(item)
332			// focus the item above
333			above := l.items[inx]
334			if focusable, ok := above.(layout.Focusable); ok {
335				cmds = append(cmds, focusable.Focus())
336			}
337			// rerender the item
338			l.renderedItems[inx] = l.renderItem(above)
339			l.selectedItem = above.ID()
340			break
341		}
342	}
343	l.renderView()
344	l.moveToSelected()
345	return tea.Batch(cmds...)
346}
347
348func (l *list) SelectItemBelow() tea.Cmd {
349	if !l.isReady {
350		return nil
351	}
352	var cmds []tea.Cmd
353	for i, item := range l.items {
354		if l.selectedItem == item.ID() {
355			inx := l.firstSelectableItemAfter(i)
356			if inx == NotFound {
357				// no item below
358				return nil
359			}
360			// blur the current item
361			if focusable, ok := item.(layout.Focusable); ok {
362				cmds = append(cmds, focusable.Blur())
363			}
364			// rerender the item
365			l.renderedItems[i] = l.renderItem(item)
366
367			// focus the item below
368			below := l.items[inx]
369			if focusable, ok := below.(layout.Focusable); ok {
370				cmds = append(cmds, focusable.Focus())
371			}
372			// rerender the item
373			l.renderedItems[inx] = l.renderItem(below)
374			l.selectedItem = below.ID()
375			break
376		}
377	}
378
379	l.renderView()
380	l.moveToSelected()
381	return tea.Batch(cmds...)
382}
383
384func (l *list) GoToTop() tea.Cmd {
385	if !l.isReady {
386		return nil
387	}
388	l.offset = 0
389	l.direction = Forward
390	return tea.Batch(l.selectFirstItem(), l.renderForward())
391}
392
393func (l *list) GoToBottom() tea.Cmd {
394	if !l.isReady {
395		return nil
396	}
397	l.offset = 0
398	l.direction = Backward
399
400	return tea.Batch(l.selectLastItem(), l.renderBackward())
401}
402
403func (l *list) renderForward() tea.Cmd {
404	// TODO: figure out a way to preserve items that did not change
405	l.renderedItems = make([]renderedItem, 0)
406	currentHeight := 0
407	currentIndex := 0
408	for i, item := range l.items {
409		currentIndex = i
410		if currentHeight-1 > l.listHeight() {
411			break
412		}
413		rendered := l.renderItem(item)
414		l.renderedItems = append(l.renderedItems, rendered)
415		currentHeight += rendered.height + l.gap
416	}
417
418	// initial render
419	l.renderView()
420
421	if currentIndex == len(l.items)-1 {
422		l.isReady = true
423		return nil
424	}
425	// render the rest
426	return func() tea.Msg {
427		for i := currentIndex; i < len(l.items); i++ {
428			rendered := l.renderItem(l.items[i])
429			l.renderedItems = append(l.renderedItems, rendered)
430		}
431		l.renderView()
432		l.isReady = true
433		return nil
434	}
435}
436
437func (l *list) renderBackward() tea.Cmd {
438	// TODO: figure out a way to preserve items that did not change
439	l.renderedItems = make([]renderedItem, 0)
440	currentHeight := 0
441	currentIndex := 0
442	for i := len(l.items) - 1; i >= 0; i-- {
443		fmt.Printf("rendering item %d\n", i)
444		currentIndex = i
445		if currentHeight > l.listHeight() {
446			break
447		}
448		rendered := l.renderItem(l.items[i])
449		l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
450		currentHeight += rendered.height + l.gap
451	}
452	// initial render
453	l.renderView()
454	if currentIndex == 0 {
455		l.isReady = true
456		return nil
457	}
458	return func() tea.Msg {
459		for i := currentIndex; i >= 0; i-- {
460			fmt.Printf("rendering item after %d\n", i)
461			rendered := l.renderItem(l.items[i])
462			l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
463		}
464		l.renderView()
465		l.isReady = true
466		return nil
467	}
468}
469
470func (l *list) selectFirstItem() tea.Cmd {
471	var cmd tea.Cmd
472	inx := l.firstSelectableItemAfter(-1)
473	if inx != NotFound {
474		l.selectedItem = l.items[inx].ID()
475		if focusable, ok := l.items[inx].(layout.Focusable); ok {
476			cmd = focusable.Focus()
477		}
478	}
479	return cmd
480}
481
482func (l *list) selectLastItem() tea.Cmd {
483	var cmd tea.Cmd
484	inx := l.firstSelectableItemBefore(len(l.items))
485	if inx != NotFound {
486		l.selectedItem = l.items[inx].ID()
487		if focusable, ok := l.items[inx].(layout.Focusable); ok {
488			cmd = focusable.Focus()
489		}
490	}
491	return cmd
492}
493
494func (l *list) renderItems() tea.Cmd {
495	if l.height <= 0 || l.width <= 0 {
496		return nil
497	}
498	if len(l.items) == 0 {
499		return nil
500	}
501
502	if l.selectedItem == "" {
503		if l.direction == Forward {
504			l.selectFirstItem()
505		} else {
506			l.selectLastItem()
507		}
508	}
509	if l.direction == Forward {
510		return l.renderForward()
511	}
512	return l.renderBackward()
513}
514
515func (l *list) listHeight() int {
516	// for the moment its the same
517	return l.height
518}
519
520func (l *list) SetItems(items []Item) tea.Cmd {
521	l.items = items
522	var cmds []tea.Cmd
523	for _, item := range l.items {
524		cmds = append(cmds, item.Init())
525		// Set height to 0 to let the item calculate its own height
526		cmds = append(cmds, item.SetSize(l.width, 0))
527	}
528	cmds = append(cmds, l.renderItems())
529	return tea.Batch(cmds...)
530}
531
532// GetSize implements List.
533func (l *list) GetSize() (int, int) {
534	return l.width, l.height
535}
536
537// SetSize implements List.
538func (l *list) SetSize(width int, height int) tea.Cmd {
539	l.width = width
540	l.height = height
541	var cmds []tea.Cmd
542	for _, item := range l.items {
543		cmds = append(cmds, item.SetSize(width, height))
544	}
545	cmds = append(cmds, l.renderItems())
546	return tea.Batch(cmds...)
547}
548
549// Blur implements List.
550func (l *list) Blur() tea.Cmd {
551	var cmd tea.Cmd
552	l.focused = false
553	for i, item := range l.items {
554		if item.ID() != l.selectedItem {
555			continue
556		}
557		if focusable, ok := item.(layout.Focusable); ok {
558			cmd = focusable.Blur()
559		}
560		l.renderedItems[i] = l.renderItem(item)
561	}
562	l.renderView()
563	return cmd
564}
565
566// Focus implements List.
567func (l *list) Focus() tea.Cmd {
568	var cmd tea.Cmd
569	l.focused = true
570	for i, item := range l.items {
571		if item.ID() != l.selectedItem {
572			continue
573		}
574		if focusable, ok := item.(layout.Focusable); ok {
575			cmd = focusable.Focus()
576		}
577		l.renderedItems[i] = l.renderItem(item)
578	}
579	l.renderView()
580	return cmd
581}
582
583// IsFocused implements List.
584func (l *list) IsFocused() bool {
585	return l.focused
586}