1package list
  2
  3import (
  4	"fmt"
  5	"testing"
  6
  7	tea "github.com/charmbracelet/bubbletea/v2"
  8	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  9	"github.com/charmbracelet/lipgloss/v2"
 10	"github.com/charmbracelet/x/exp/golden"
 11	"github.com/google/uuid"
 12	"github.com/stretchr/testify/assert"
 13)
 14
 15func TestListPosition(t *testing.T) {
 16	type positionOffsetTest struct {
 17		dir      direction
 18		test     string
 19		width    int
 20		height   int
 21		numItems int
 22
 23		moveUp   int
 24		moveDown int
 25
 26		expectedStart int
 27		expectedEnd   int
 28	}
 29	tests := []positionOffsetTest{
 30		{
 31			dir:           Forward,
 32			test:          "should have correct position initially when forward",
 33			moveUp:        0,
 34			moveDown:      0,
 35			width:         10,
 36			height:        20,
 37			numItems:      100,
 38			expectedStart: 0,
 39			expectedEnd:   19,
 40		},
 41		{
 42			dir:           Forward,
 43			test:          "should offset start and end by one when moving down by one",
 44			moveUp:        0,
 45			moveDown:      1,
 46			width:         10,
 47			height:        20,
 48			numItems:      100,
 49			expectedStart: 1,
 50			expectedEnd:   20,
 51		},
 52		{
 53			dir:           Backward,
 54			test:          "should have correct position initially when backward",
 55			moveUp:        0,
 56			moveDown:      0,
 57			width:         10,
 58			height:        20,
 59			numItems:      100,
 60			expectedStart: 80,
 61			expectedEnd:   99,
 62		},
 63		{
 64			dir:           Backward,
 65			test:          "should offset the start and end by one when moving up by one",
 66			moveUp:        1,
 67			moveDown:      0,
 68			width:         10,
 69			height:        20,
 70			numItems:      100,
 71			expectedStart: 79,
 72			expectedEnd:   98,
 73		},
 74	}
 75	for _, c := range tests {
 76		t.Run(c.test, func(t *testing.T) {
 77			l := New(WithDirection(c.dir)).(*list)
 78			l.SetSize(c.width, c.height)
 79			items := []Item{}
 80			for i := range c.numItems {
 81				item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 82				items = append(items, item)
 83			}
 84			cmd := l.SetItems(items)
 85			if cmd != nil {
 86				cmd()
 87			}
 88
 89			if c.moveUp > 0 {
 90				l.MoveUp(c.moveUp)
 91			}
 92			if c.moveDown > 0 {
 93				l.MoveDown(c.moveDown)
 94			}
 95			start, end := l.viewPosition()
 96			assert.Equal(t, c.expectedStart, start)
 97			assert.Equal(t, c.expectedEnd, end)
 98		})
 99	}
100}
101
102func TestBackwardList(t *testing.T) {
103	t.Run("within height", func(t *testing.T) {
104		t.Parallel()
105		l := New(WithDirection(Backward), WithGap(1)).(*list)
106		l.SetSize(10, 20)
107		items := []Item{}
108		for i := range 5 {
109			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
110			items = append(items, item)
111		}
112		cmd := l.SetItems(items)
113		if cmd != nil {
114			cmd()
115		}
116
117		// should select the last item
118		assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
119
120		golden.RequireEqual(t, []byte(l.View()))
121	})
122	t.Run("should not change selected item", func(t *testing.T) {
123		t.Parallel()
124		items := []Item{}
125		for i := range 5 {
126			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
127			items = append(items, item)
128		}
129		l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
130		l.SetSize(10, 20)
131		cmd := l.SetItems(items)
132		if cmd != nil {
133			cmd()
134		}
135		// should select the last item
136		assert.Equal(t, l.selectedItem, items[2].ID())
137	})
138	t.Run("more than height", func(t *testing.T) {
139		t.Parallel()
140		l := New(WithDirection(Backward))
141		l.SetSize(10, 5)
142		items := []Item{}
143		for i := range 10 {
144			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
145			items = append(items, item)
146		}
147		cmd := l.SetItems(items)
148		if cmd != nil {
149			cmd()
150		}
151
152		golden.RequireEqual(t, []byte(l.View()))
153	})
154	t.Run("more than height multi line", func(t *testing.T) {
155		t.Parallel()
156		l := New(WithDirection(Backward))
157		l.SetSize(10, 5)
158		items := []Item{}
159		for i := range 10 {
160			item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
161			items = append(items, item)
162		}
163		cmd := l.SetItems(items)
164		if cmd != nil {
165			cmd()
166		}
167
168		golden.RequireEqual(t, []byte(l.View()))
169	})
170	t.Run("should move up", func(t *testing.T) {
171		t.Parallel()
172		l := New(WithDirection(Backward)).(*list)
173		l.SetSize(10, 5)
174		items := []Item{}
175		for i := range 10 {
176			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
177			items = append(items, item)
178		}
179		cmd := l.SetItems(items)
180		if cmd != nil {
181			cmd()
182		}
183
184		l.MoveUp(1)
185		golden.RequireEqual(t, []byte(l.View()))
186	})
187
188	t.Run("should move at max to the top", func(t *testing.T) {
189		l := New(WithDirection(Backward)).(*list)
190		l.SetSize(10, 5)
191		items := []Item{}
192		for i := range 10 {
193			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
194			items = append(items, item)
195		}
196		cmd := l.SetItems(items)
197		if cmd != nil {
198			cmd()
199		}
200
201		l.MoveUp(100)
202		assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
203		golden.RequireEqual(t, []byte(l.View()))
204	})
205	t.Run("should do nothing with wrong move number", func(t *testing.T) {
206		t.Parallel()
207		l := New(WithDirection(Backward)).(*list)
208		l.SetSize(10, 5)
209		items := []Item{}
210		for i := range 10 {
211			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
212			items = append(items, item)
213		}
214		cmd := l.SetItems(items)
215		if cmd != nil {
216			cmd()
217		}
218
219		l.MoveUp(-10)
220		golden.RequireEqual(t, []byte(l.View()))
221	})
222	t.Run("should move to the top", func(t *testing.T) {
223		t.Parallel()
224		l := New(WithDirection(Backward)).(*list)
225		l.SetSize(10, 5)
226		items := []Item{}
227		for i := range 10 {
228			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
229			items = append(items, item)
230		}
231		cmd := l.SetItems(items)
232		if cmd != nil {
233			cmd()
234		}
235
236		l.GoToTop()
237		assert.Equal(t, l.direction, Forward)
238		golden.RequireEqual(t, []byte(l.View()))
239	})
240	t.Run("should select the item above", func(t *testing.T) {
241		t.Parallel()
242		l := New(WithDirection(Backward)).(*list)
243		l.SetSize(10, 5)
244		items := []Item{}
245		for i := range 10 {
246			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
247			items = append(items, item)
248		}
249		cmd := l.SetItems(items)
250		if cmd != nil {
251			cmd()
252		}
253
254		selectedInx := len(l.items) - 2
255		currentItem := items[len(l.items)-1]
256		nextItem := items[selectedInx]
257		assert.False(t, nextItem.(SelectableItem).IsFocused())
258		assert.True(t, currentItem.(SelectableItem).IsFocused())
259		cmd = l.SelectItemAbove()
260		if cmd != nil {
261			cmd()
262		}
263
264		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
265		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
266
267		golden.RequireEqual(t, []byte(l.View()))
268	})
269	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
270		t.Parallel()
271		l := New(WithDirection(Backward)).(*list)
272		l.SetSize(10, 5)
273		items := []Item{}
274		for i := range 10 {
275			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
276			items = append(items, item)
277		}
278		cmd := l.SetItems(items)
279		if cmd != nil {
280			cmd()
281		}
282
283		for range 5 {
284			cmd = l.SelectItemAbove()
285			if cmd != nil {
286				cmd()
287			}
288		}
289		golden.RequireEqual(t, []byte(l.View()))
290	})
291}
292
293func TestForwardList(t *testing.T) {
294	t.Run("within height", func(t *testing.T) {
295		t.Parallel()
296		l := New(WithDirection(Forward), WithGap(1)).(*list)
297		l.SetSize(10, 20)
298		items := []Item{}
299		for i := range 5 {
300			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
301			items = append(items, item)
302		}
303		cmd := l.SetItems(items)
304		if cmd != nil {
305			cmd()
306		}
307
308		// should select the last item
309		assert.Equal(t, l.selectedItem, items[0].ID())
310
311		golden.RequireEqual(t, []byte(l.View()))
312	})
313	t.Run("should not change selected item", func(t *testing.T) {
314		t.Parallel()
315		items := []Item{}
316		for i := range 5 {
317			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
318			items = append(items, item)
319		}
320		l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
321		l.SetSize(10, 20)
322		cmd := l.SetItems(items)
323		if cmd != nil {
324			cmd()
325		}
326		// should select the last item
327		assert.Equal(t, l.selectedItem, items[2].ID())
328	})
329	t.Run("more than height", func(t *testing.T) {
330		t.Parallel()
331		l := New(WithDirection(Forward))
332		l.SetSize(10, 5)
333		items := []Item{}
334		for i := range 10 {
335			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
336			items = append(items, item)
337		}
338		cmd := l.SetItems(items)
339		if cmd != nil {
340			cmd()
341		}
342
343		golden.RequireEqual(t, []byte(l.View()))
344	})
345	t.Run("more than height multi line", func(t *testing.T) {
346		t.Parallel()
347		l := New(WithDirection(Forward))
348		l.SetSize(10, 5)
349		items := []Item{}
350		for i := range 10 {
351			item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
352			items = append(items, item)
353		}
354		cmd := l.SetItems(items)
355		if cmd != nil {
356			cmd()
357		}
358
359		golden.RequireEqual(t, []byte(l.View()))
360	})
361	t.Run("should move down", func(t *testing.T) {
362		t.Parallel()
363		l := New(WithDirection(Forward)).(*list)
364		l.SetSize(10, 5)
365		items := []Item{}
366		for i := range 10 {
367			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
368			items = append(items, item)
369		}
370		cmd := l.SetItems(items)
371		if cmd != nil {
372			cmd()
373		}
374
375		l.MoveDown(1)
376		golden.RequireEqual(t, []byte(l.View()))
377	})
378	t.Run("should move at max to the bottom", func(t *testing.T) {
379		t.Parallel()
380		l := New(WithDirection(Forward)).(*list)
381		l.SetSize(10, 5)
382		items := []Item{}
383		for i := range 10 {
384			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
385			items = append(items, item)
386		}
387		cmd := l.SetItems(items)
388		if cmd != nil {
389			cmd()
390		}
391
392		l.MoveDown(100)
393		assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
394		golden.RequireEqual(t, []byte(l.View()))
395	})
396	t.Run("should do nothing with wrong move number", func(t *testing.T) {
397		t.Parallel()
398		l := New(WithDirection(Forward)).(*list)
399		l.SetSize(10, 5)
400		items := []Item{}
401		for i := range 10 {
402			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
403			items = append(items, item)
404		}
405		cmd := l.SetItems(items)
406		if cmd != nil {
407			cmd()
408		}
409
410		l.MoveDown(-10)
411		golden.RequireEqual(t, []byte(l.View()))
412	})
413	t.Run("should move to the bottom", func(t *testing.T) {
414		t.Parallel()
415		l := New(WithDirection(Forward)).(*list)
416		l.SetSize(10, 5)
417		items := []Item{}
418		for i := range 10 {
419			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
420			items = append(items, item)
421		}
422		cmd := l.SetItems(items)
423		if cmd != nil {
424			cmd()
425		}
426
427		l.GoToBottom()
428		assert.Equal(t, l.direction, Backward)
429		golden.RequireEqual(t, []byte(l.View()))
430	})
431	t.Run("should select the item below", func(t *testing.T) {
432		t.Parallel()
433		l := New(WithDirection(Forward)).(*list)
434		l.SetSize(10, 5)
435		items := []Item{}
436		for i := range 10 {
437			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
438			items = append(items, item)
439		}
440		cmd := l.SetItems(items)
441		if cmd != nil {
442			cmd()
443		}
444
445		selectedInx := 1
446		currentItem := items[0]
447		nextItem := items[selectedInx]
448		assert.False(t, nextItem.(SelectableItem).IsFocused())
449		assert.True(t, currentItem.(SelectableItem).IsFocused())
450		cmd = l.SelectItemBelow()
451		if cmd != nil {
452			cmd()
453		}
454
455		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
456		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
457
458		golden.RequireEqual(t, []byte(l.View()))
459	})
460	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
461		t.Parallel()
462		l := New(WithDirection(Backward)).(*list)
463		l.SetSize(10, 5)
464		items := []Item{}
465		for i := range 10 {
466			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
467			items = append(items, item)
468		}
469		cmd := l.SetItems(items)
470		if cmd != nil {
471			cmd()
472		}
473
474		for range 5 {
475			cmd = l.SelectItemBelow()
476			if cmd != nil {
477				cmd()
478			}
479		}
480		golden.RequireEqual(t, []byte(l.View()))
481	})
482}
483
484func TestListSelection(t *testing.T) {
485	t.Run("should skip none selectable items initially", func(t *testing.T) {
486		t.Parallel()
487		l := New(WithDirection(Forward)).(*list)
488		l.SetSize(100, 10)
489		items := []Item{}
490		items = append(items, NewSimpleItem("None Selectable"))
491		for i := range 5 {
492			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
493			items = append(items, item)
494		}
495		cmd := l.SetItems(items)
496		if cmd != nil {
497			cmd()
498		}
499
500		assert.Equal(t, items[1].ID(), l.selectedItem)
501		golden.RequireEqual(t, []byte(l.View()))
502	})
503}
504
505type SelectableItem interface {
506	Item
507	layout.Focusable
508}
509
510type simpleItem struct {
511	width   int
512	content string
513	id      string
514}
515type selectableItem struct {
516	*simpleItem
517	focused bool
518}
519
520func NewSimpleItem(content string) *simpleItem {
521	return &simpleItem{
522		id:      uuid.NewString(),
523		width:   0,
524		content: content,
525	}
526}
527
528func NewSelectsableItem(content string) SelectableItem {
529	return &selectableItem{
530		simpleItem: NewSimpleItem(content),
531		focused:    false,
532	}
533}
534
535func (s *simpleItem) ID() string {
536	return s.id
537}
538
539func (s *simpleItem) Init() tea.Cmd {
540	return nil
541}
542
543func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
544	return s, nil
545}
546
547func (s *simpleItem) View() string {
548	return lipgloss.NewStyle().Width(s.width).Render(s.content)
549}
550
551func (l *simpleItem) GetSize() (int, int) {
552	return l.width, 0
553}
554
555// SetSize implements Item.
556func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
557	s.width = width
558	return nil
559}
560
561func (s *selectableItem) View() string {
562	if s.focused {
563		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
564	}
565	return lipgloss.NewStyle().Width(s.width).Render(s.content)
566}
567
568// Blur implements SimpleItem.
569func (s *selectableItem) Blur() tea.Cmd {
570	s.focused = false
571	return nil
572}
573
574// Focus implements SimpleItem.
575func (s *selectableItem) Focus() tea.Cmd {
576	s.focused = true
577	return nil
578}
579
580// IsFocused implements SimpleItem.
581func (s *selectableItem) IsFocused() bool {
582	return s.focused
583}