list_test.go

  1package list
  2
  3import (
  4	"fmt"
  5	"strings"
  6	"testing"
  7
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 10	"github.com/charmbracelet/lipgloss/v2"
 11	"github.com/charmbracelet/x/exp/golden"
 12	"github.com/google/uuid"
 13	"github.com/stretchr/testify/assert"
 14	"github.com/stretchr/testify/require"
 15)
 16
 17func TestList(t *testing.T) {
 18	t.Parallel()
 19	t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
 20		t.Parallel()
 21		items := []Item{}
 22		for i := range 5 {
 23			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 24			items = append(items, item)
 25		}
 26		l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
 27		execCmd(l, l.Init())
 28
 29		// should select the last item
 30		assert.Equal(t, items[0].ID(), l.selectedItem)
 31		assert.Equal(t, 0, l.offset)
 32		require.Len(t, l.indexMap, 5)
 33		require.Len(t, l.items, 5)
 34		require.Len(t, l.renderedItems, 5)
 35		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 36		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 37		start, end := l.viewPosition()
 38		assert.Equal(t, 0, start)
 39		assert.Equal(t, 4, end)
 40		for i := range 5 {
 41			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
 42			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
 43		}
 44
 45		golden.RequireEqual(t, []byte(l.View()))
 46	})
 47	t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
 48		t.Parallel()
 49		items := []Item{}
 50		for i := range 5 {
 51			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 52			items = append(items, item)
 53		}
 54		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
 55		execCmd(l, l.Init())
 56
 57		// should select the last item
 58		assert.Equal(t, items[4].ID(), l.selectedItem)
 59		assert.Equal(t, 0, l.offset)
 60		require.Len(t, l.indexMap, 5)
 61		require.Len(t, l.items, 5)
 62		require.Len(t, l.renderedItems, 5)
 63		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 64		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 65		start, end := l.viewPosition()
 66		assert.Equal(t, 0, start)
 67		assert.Equal(t, 4, end)
 68		for i := range 5 {
 69			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
 70			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
 71		}
 72
 73		golden.RequireEqual(t, []byte(l.View()))
 74	})
 75
 76	t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
 77		t.Parallel()
 78		items := []Item{}
 79		for i := range 30 {
 80			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 81			items = append(items, item)
 82		}
 83		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 84		execCmd(l, l.Init())
 85
 86		// should select the last item
 87		assert.Equal(t, items[0].ID(), l.selectedItem)
 88		assert.Equal(t, 0, l.offset)
 89		require.Len(t, l.indexMap, 30)
 90		require.Len(t, l.items, 30)
 91		require.Len(t, l.renderedItems, 30)
 92		assert.Equal(t, 30, lipgloss.Height(l.rendered))
 93		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 94		start, end := l.viewPosition()
 95		assert.Equal(t, 0, start)
 96		assert.Equal(t, 9, end)
 97		for i := range 30 {
 98			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
 99			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
100		}
101
102		golden.RequireEqual(t, []byte(l.View()))
103	})
104	t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
105		t.Parallel()
106		items := []Item{}
107		for i := range 30 {
108			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
109			items = append(items, item)
110		}
111		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
112		execCmd(l, l.Init())
113
114		// should select the last item
115		assert.Equal(t, items[29].ID(), l.selectedItem)
116		assert.Equal(t, 0, l.offset)
117		require.Len(t, l.indexMap, 30)
118		require.Len(t, l.items, 30)
119		require.Len(t, l.renderedItems, 30)
120		assert.Equal(t, 30, lipgloss.Height(l.rendered))
121		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
122		start, end := l.viewPosition()
123		assert.Equal(t, 20, start)
124		assert.Equal(t, 29, end)
125		for i := range 30 {
126			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
127			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
128		}
129
130		golden.RequireEqual(t, []byte(l.View()))
131	})
132
133	t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
134		t.Parallel()
135		items := []Item{}
136		for i := range 30 {
137			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
138			content = strings.TrimSuffix(content, "\n")
139			item := NewSelectableItem(content)
140			items = append(items, item)
141		}
142		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
143		execCmd(l, l.Init())
144
145		// should select the last item
146		assert.Equal(t, items[0].ID(), l.selectedItem)
147		assert.Equal(t, 0, l.offset)
148		require.Len(t, l.indexMap, 30)
149		require.Len(t, l.items, 30)
150		require.Len(t, l.renderedItems, 30)
151		expectedLines := 0
152		for i := range 30 {
153			expectedLines += (i + 1) * 1
154		}
155		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
156		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
157		start, end := l.viewPosition()
158		assert.Equal(t, 0, start)
159		assert.Equal(t, 9, end)
160		currentPosition := 0
161		for i := range 30 {
162			rItem := l.renderedItems[items[i].ID()]
163			assert.Equal(t, currentPosition, rItem.start)
164			assert.Equal(t, currentPosition+i, rItem.end)
165			currentPosition += i + 1
166		}
167
168		golden.RequireEqual(t, []byte(l.View()))
169	})
170	t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
171		t.Parallel()
172		items := []Item{}
173		for i := range 30 {
174			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
175			content = strings.TrimSuffix(content, "\n")
176			item := NewSelectableItem(content)
177			items = append(items, item)
178		}
179		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
180		execCmd(l, l.Init())
181
182		// should select the last item
183		assert.Equal(t, items[29].ID(), l.selectedItem)
184		assert.Equal(t, 0, l.offset)
185		require.Len(t, l.indexMap, 30)
186		require.Len(t, l.items, 30)
187		require.Len(t, l.renderedItems, 30)
188		expectedLines := 0
189		for i := range 30 {
190			expectedLines += (i + 1) * 1
191		}
192		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
193		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
194		start, end := l.viewPosition()
195		assert.Equal(t, expectedLines-10, start)
196		assert.Equal(t, expectedLines-1, end)
197		currentPosition := 0
198		for i := range 30 {
199			rItem := l.renderedItems[items[i].ID()]
200			assert.Equal(t, currentPosition, rItem.start)
201			assert.Equal(t, currentPosition+i, rItem.end)
202			currentPosition += i + 1
203		}
204
205		golden.RequireEqual(t, []byte(l.View()))
206	})
207
208	t.Run("should go to selected item and center", func(t *testing.T) {
209		t.Parallel()
210		items := []Item{}
211		for i := range 30 {
212			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
213			content = strings.TrimSuffix(content, "\n")
214			item := NewSelectableItem(content)
215			items = append(items, item)
216		}
217		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
218		execCmd(l, l.Init())
219
220		// should select the last item
221		assert.Equal(t, items[4].ID(), l.selectedItem)
222
223		golden.RequireEqual(t, []byte(l.View()))
224	})
225
226	t.Run("should go to selected item and center backwards", func(t *testing.T) {
227		t.Parallel()
228		items := []Item{}
229		for i := range 30 {
230			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
231			content = strings.TrimSuffix(content, "\n")
232			item := NewSelectableItem(content)
233			items = append(items, item)
234		}
235		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
236		execCmd(l, l.Init())
237
238		// should select the last item
239		assert.Equal(t, items[4].ID(), l.selectedItem)
240
241		golden.RequireEqual(t, []byte(l.View()))
242	})
243
244	t.Run("should go to selected item at the beginning", func(t *testing.T) {
245		t.Parallel()
246		items := []Item{}
247		for i := range 30 {
248			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
249			content = strings.TrimSuffix(content, "\n")
250			item := NewSelectableItem(content)
251			items = append(items, item)
252		}
253		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
254		execCmd(l, l.Init())
255
256		// should select the last item
257		assert.Equal(t, items[10].ID(), l.selectedItem)
258
259		golden.RequireEqual(t, []byte(l.View()))
260	})
261
262	t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
263		t.Parallel()
264		items := []Item{}
265		for i := range 30 {
266			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
267			content = strings.TrimSuffix(content, "\n")
268			item := NewSelectableItem(content)
269			items = append(items, item)
270		}
271		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
272		execCmd(l, l.Init())
273
274		// should select the last item
275		assert.Equal(t, items[10].ID(), l.selectedItem)
276
277		golden.RequireEqual(t, []byte(l.View()))
278	})
279}
280
281func TestListMovement(t *testing.T) {
282	t.Parallel()
283	t.Run("should move viewport up", func(t *testing.T) {
284		t.Parallel()
285		items := []Item{}
286		for i := range 30 {
287			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
288			content = strings.TrimSuffix(content, "\n")
289			item := NewSelectableItem(content)
290			items = append(items, item)
291		}
292		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
293		execCmd(l, l.Init())
294
295		execCmd(l, l.MoveUp(25))
296
297		assert.Equal(t, 25, l.offset)
298		golden.RequireEqual(t, []byte(l.View()))
299	})
300	t.Run("should move viewport up and down", func(t *testing.T) {
301		t.Parallel()
302		items := []Item{}
303		for i := range 30 {
304			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
305			content = strings.TrimSuffix(content, "\n")
306			item := NewSelectableItem(content)
307			items = append(items, item)
308		}
309		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
310		execCmd(l, l.Init())
311
312		execCmd(l, l.MoveUp(25))
313		execCmd(l, l.MoveDown(25))
314
315		assert.Equal(t, 0, l.offset)
316		golden.RequireEqual(t, []byte(l.View()))
317	})
318
319	t.Run("should move viewport down", func(t *testing.T) {
320		t.Parallel()
321		items := []Item{}
322		for i := range 30 {
323			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
324			content = strings.TrimSuffix(content, "\n")
325			item := NewSelectableItem(content)
326			items = append(items, item)
327		}
328		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
329		execCmd(l, l.Init())
330
331		execCmd(l, l.MoveDown(25))
332
333		assert.Equal(t, 25, l.offset)
334		golden.RequireEqual(t, []byte(l.View()))
335	})
336	t.Run("should move viewport down and up", func(t *testing.T) {
337		t.Parallel()
338		items := []Item{}
339		for i := range 30 {
340			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
341			content = strings.TrimSuffix(content, "\n")
342			item := NewSelectableItem(content)
343			items = append(items, item)
344		}
345		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
346		execCmd(l, l.Init())
347
348		execCmd(l, l.MoveDown(25))
349		execCmd(l, l.MoveUp(25))
350
351		assert.Equal(t, 0, l.offset)
352		golden.RequireEqual(t, []byte(l.View()))
353	})
354}
355
356type SelectableItem interface {
357	Item
358	layout.Focusable
359}
360
361type simpleItem struct {
362	width   int
363	content string
364	id      string
365}
366type selectableItem struct {
367	*simpleItem
368	focused bool
369}
370
371func NewSimpleItem(content string) *simpleItem {
372	return &simpleItem{
373		id:      uuid.NewString(),
374		width:   0,
375		content: content,
376	}
377}
378
379func NewSelectableItem(content string) SelectableItem {
380	return &selectableItem{
381		simpleItem: NewSimpleItem(content),
382		focused:    false,
383	}
384}
385
386func (s *simpleItem) ID() string {
387	return s.id
388}
389
390func (s *simpleItem) Init() tea.Cmd {
391	return nil
392}
393
394func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
395	return s, nil
396}
397
398func (s *simpleItem) View() string {
399	return lipgloss.NewStyle().Width(s.width).Render(s.content)
400}
401
402func (l *simpleItem) GetSize() (int, int) {
403	return l.width, 0
404}
405
406// SetSize implements Item.
407func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
408	s.width = width
409	return nil
410}
411
412func (s *selectableItem) View() string {
413	if s.focused {
414		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
415	}
416	return lipgloss.NewStyle().Width(s.width).Render(s.content)
417}
418
419// Blur implements SimpleItem.
420func (s *selectableItem) Blur() tea.Cmd {
421	s.focused = false
422	return nil
423}
424
425// Focus implements SimpleItem.
426func (s *selectableItem) Focus() tea.Cmd {
427	s.focused = true
428	return nil
429}
430
431// IsFocused implements SimpleItem.
432func (s *selectableItem) IsFocused() bool {
433	return s.focused
434}
435
436func execCmd(m tea.Model, cmd tea.Cmd) {
437	for cmd != nil {
438		msg := cmd()
439		m, cmd = m.Update(msg)
440	}
441}