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 item 10
 30		assert.Equal(t, items[0].ID(), l.SelectedItemID())
 31		assert.Equal(t, 0, l.offset)
 32		require.Equal(t, 5, l.indexMap.Len())
 33		require.Equal(t, 5, l.items.Len())
 34		require.Equal(t, 5, len(l.itemPositions))
 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			item := l.itemPositions[i]
 42			assert.Equal(t, i, item.start)
 43			assert.Equal(t, i, item.end)
 44		}
 45
 46		golden.RequireEqual(t, []byte(l.View()))
 47	})
 48	t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
 49		t.Parallel()
 50		items := []Item{}
 51		for i := range 5 {
 52			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 53			items = append(items, item)
 54		}
 55		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
 56		execCmd(l, l.Init())
 57
 58		// should select item 10
 59		assert.Equal(t, items[4].ID(), l.SelectedItemID())
 60		assert.Equal(t, 0, l.offset)
 61		require.Equal(t, 5, l.indexMap.Len())
 62		require.Equal(t, 5, l.items.Len())
 63		require.Equal(t, 5, len(l.itemPositions))
 64		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 65		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 66		start, end := l.viewPosition()
 67		assert.Equal(t, 0, start)
 68		assert.Equal(t, 4, end)
 69		for i := range 5 {
 70			item := l.itemPositions[i]
 71			assert.Equal(t, i, item.start)
 72			assert.Equal(t, i, item.end)
 73		}
 74
 75		golden.RequireEqual(t, []byte(l.View()))
 76	})
 77
 78	t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
 79		t.Parallel()
 80		items := []Item{}
 81		for i := range 30 {
 82			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 83			items = append(items, item)
 84		}
 85		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 86		execCmd(l, l.Init())
 87
 88		// should select item 10
 89		assert.Equal(t, items[0].ID(), l.SelectedItemID())
 90		assert.Equal(t, 0, l.offset)
 91		require.Equal(t, 30, l.indexMap.Len())
 92		require.Equal(t, 30, l.items.Len())
 93		require.Equal(t, 30, len(l.itemPositions))
 94		// With virtual scrolling, rendered height should be viewport height (10)
 95		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 96		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 97		start, end := l.viewPosition()
 98		assert.Equal(t, 0, start)
 99		assert.Equal(t, 9, end)
100		for i := range 30 {
101			item := l.itemPositions[i]
102			assert.Equal(t, i, item.start)
103			assert.Equal(t, i, item.end)
104		}
105
106		golden.RequireEqual(t, []byte(l.View()))
107	})
108	t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
109		t.Parallel()
110		items := []Item{}
111		for i := range 30 {
112			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
113			items = append(items, item)
114		}
115		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
116		execCmd(l, l.Init())
117
118		// should select item 10
119		assert.Equal(t, items[29].ID(), l.SelectedItemID())
120		assert.Equal(t, 0, l.offset)
121		require.Equal(t, 30, l.indexMap.Len())
122		require.Equal(t, 30, l.items.Len())
123		require.Equal(t, 30, len(l.itemPositions))
124		// With virtual scrolling, rendered height should be viewport height (10)
125		assert.Equal(t, 10, lipgloss.Height(l.rendered))
126		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
127		start, end := l.viewPosition()
128		assert.Equal(t, 20, start)
129		assert.Equal(t, 29, end)
130		for i := range 30 {
131			item := l.itemPositions[i]
132			assert.Equal(t, i, item.start)
133			assert.Equal(t, i, item.end)
134		}
135
136		golden.RequireEqual(t, []byte(l.View()))
137	})
138
139	t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
140		t.Parallel()
141		items := []Item{}
142		for i := range 30 {
143			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
144			content = strings.TrimSuffix(content, "\n")
145			item := NewSelectableItem(content)
146			items = append(items, item)
147		}
148		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
149		execCmd(l, l.Init())
150
151		// should select item 10
152		assert.Equal(t, items[0].ID(), l.SelectedItemID())
153		assert.Equal(t, 0, l.offset)
154		require.Equal(t, 30, l.indexMap.Len())
155		require.Equal(t, 30, l.items.Len())
156		require.Equal(t, 30, len(l.itemPositions))
157		expectedLines := 0
158		for i := range 30 {
159			expectedLines += (i + 1) * 1
160		}
161		// With virtual scrolling, rendered height should be viewport height (10)
162		assert.Equal(t, 10, lipgloss.Height(l.rendered))
163		if len(l.rendered) > 0 {
164			assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
165		}
166		start, end := l.viewPosition()
167		assert.Equal(t, 0, start)
168		assert.Equal(t, 9, end)
169		currentPosition := 0
170		for i := range 30 {
171			rItem := l.itemPositions[i]
172			assert.Equal(t, currentPosition, rItem.start)
173			assert.Equal(t, currentPosition+i, rItem.end)
174			currentPosition += i + 1
175		}
176
177		golden.RequireEqual(t, []byte(l.View()))
178	})
179	t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
180		t.Parallel()
181		items := []Item{}
182		for i := range 30 {
183			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
184			content = strings.TrimSuffix(content, "\n")
185			item := NewSelectableItem(content)
186			items = append(items, item)
187		}
188		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
189		execCmd(l, l.Init())
190
191		// should select item 10
192		assert.Equal(t, items[29].ID(), l.SelectedItemID())
193		assert.Equal(t, 0, l.offset)
194		require.Equal(t, 30, l.indexMap.Len())
195		require.Equal(t, 30, l.items.Len())
196		require.Equal(t, 30, len(l.itemPositions))
197		expectedLines := 0
198		for i := range 30 {
199			expectedLines += (i + 1) * 1
200		}
201		// With virtual scrolling, rendered height should be viewport height (10)
202		assert.Equal(t, 10, lipgloss.Height(l.rendered))
203		if len(l.rendered) > 0 {
204			assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
205		}
206		start, end := l.viewPosition()
207		assert.Equal(t, expectedLines-10, start)
208		assert.Equal(t, expectedLines-1, end)
209		currentPosition := 0
210		for i := range 30 {
211			rItem := l.itemPositions[i]
212			assert.Equal(t, currentPosition, rItem.start)
213			assert.Equal(t, currentPosition+i, rItem.end)
214			currentPosition += i + 1
215		}
216
217		golden.RequireEqual(t, []byte(l.View()))
218	})
219
220	t.Run("should go to selected item at the beginning", func(t *testing.T) {
221		t.Parallel()
222		items := []Item{}
223		for i := range 30 {
224			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
225			content = strings.TrimSuffix(content, "\n")
226			item := NewSelectableItem(content)
227			items = append(items, item)
228		}
229		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
230		execCmd(l, l.Init())
231
232		// should select item 10
233		assert.Equal(t, items[10].ID(), l.SelectedItemID())
234
235		golden.RequireEqual(t, []byte(l.View()))
236	})
237
238	t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
239		t.Parallel()
240		items := []Item{}
241		for i := range 30 {
242			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
243			content = strings.TrimSuffix(content, "\n")
244			item := NewSelectableItem(content)
245			items = append(items, item)
246		}
247		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
248		execCmd(l, l.Init())
249
250		// should select item 10
251		assert.Equal(t, items[10].ID(), l.SelectedItemID())
252
253		golden.RequireEqual(t, []byte(l.View()))
254	})
255}
256
257func TestListMovement(t *testing.T) {
258	t.Parallel()
259	t.Run("should move viewport up", func(t *testing.T) {
260		t.Parallel()
261		items := []Item{}
262		for i := range 30 {
263			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
264			content = strings.TrimSuffix(content, "\n")
265			item := NewSelectableItem(content)
266			items = append(items, item)
267		}
268		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
269		execCmd(l, l.Init())
270
271		execCmd(l, l.MoveUp(25))
272
273		assert.Equal(t, 25, l.offset)
274		golden.RequireEqual(t, []byte(l.View()))
275	})
276	t.Run("should move viewport up and down", func(t *testing.T) {
277		t.Parallel()
278		items := []Item{}
279		for i := range 30 {
280			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
281			content = strings.TrimSuffix(content, "\n")
282			item := NewSelectableItem(content)
283			items = append(items, item)
284		}
285		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
286		execCmd(l, l.Init())
287
288		execCmd(l, l.MoveUp(25))
289		execCmd(l, l.MoveDown(25))
290
291		assert.Equal(t, 0, l.offset)
292		golden.RequireEqual(t, []byte(l.View()))
293	})
294
295	t.Run("should move viewport down", func(t *testing.T) {
296		t.Parallel()
297		items := []Item{}
298		for i := range 30 {
299			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
300			content = strings.TrimSuffix(content, "\n")
301			item := NewSelectableItem(content)
302			items = append(items, item)
303		}
304		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
305		execCmd(l, l.Init())
306
307		execCmd(l, l.MoveDown(25))
308
309		assert.Equal(t, 25, l.offset)
310		golden.RequireEqual(t, []byte(l.View()))
311	})
312	t.Run("should move viewport down and up", func(t *testing.T) {
313		t.Parallel()
314		items := []Item{}
315		for i := range 30 {
316			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
317			content = strings.TrimSuffix(content, "\n")
318			item := NewSelectableItem(content)
319			items = append(items, item)
320		}
321		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
322		execCmd(l, l.Init())
323
324		execCmd(l, l.MoveDown(25))
325		execCmd(l, l.MoveUp(25))
326
327		assert.Equal(t, 0, l.offset)
328		golden.RequireEqual(t, []byte(l.View()))
329	})
330
331	t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
332		t.Parallel()
333		items := []Item{}
334		for i := range 30 {
335			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
336			content = strings.TrimSuffix(content, "\n")
337			item := NewSelectableItem(content)
338			items = append(items, item)
339		}
340		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
341		execCmd(l, l.Init())
342		execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
343
344		assert.Equal(t, 0, l.offset)
345		golden.RequireEqual(t, []byte(l.View()))
346	})
347
348	t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
349		t.Parallel()
350		items := []Item{}
351		for i := range 30 {
352			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
353			items = append(items, item)
354		}
355		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
356		execCmd(l, l.Init())
357
358		execCmd(l, l.MoveUp(2))
359		viewBefore := l.View()
360		execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
361		viewAfter := l.View()
362		assert.Equal(t, viewBefore, viewAfter)
363		assert.Equal(t, 5, l.offset)
364		// With virtual scrolling, rendered height should be viewport height (10)
365		assert.Equal(t, 10, lipgloss.Height(l.rendered))
366		golden.RequireEqual(t, []byte(l.View()))
367	})
368	t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
369		t.Parallel()
370		items := []Item{}
371		for i := range 30 {
372			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
373			items = append(items, item)
374		}
375		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
376		execCmd(l, l.Init())
377
378		execCmd(l, l.MoveUp(2))
379		viewBefore := l.View()
380		item := items[29]
381		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
382		viewAfter := l.View()
383		assert.Equal(t, viewBefore, viewAfter)
384		assert.Equal(t, 4, l.offset)
385		// With virtual scrolling, rendered height should be viewport height (10)
386		assert.Equal(t, 10, lipgloss.Height(l.rendered))
387		golden.RequireEqual(t, []byte(l.View()))
388	})
389	t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
390		t.Parallel()
391		items := []Item{}
392		for i := range 30 {
393			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
394			items = append(items, item)
395		}
396		items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
397		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
398		execCmd(l, l.Init())
399
400		execCmd(l, l.MoveUp(2))
401		viewBefore := l.View()
402		item := items[30]
403		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
404		viewAfter := l.View()
405		assert.Equal(t, viewBefore, viewAfter)
406		assert.Equal(t, 0, l.offset)
407		// With virtual scrolling, rendered height should be viewport height (10)
408		assert.Equal(t, 10, lipgloss.Height(l.rendered))
409		golden.RequireEqual(t, []byte(l.View()))
410	})
411	t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
412		t.Parallel()
413		items := []Item{}
414		for i := range 30 {
415			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
416			items = append(items, item)
417		}
418		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
419		execCmd(l, l.Init())
420
421		execCmd(l, l.MoveUp(2))
422		viewBefore := l.View()
423		item := items[1]
424		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
425		viewAfter := l.View()
426		assert.Equal(t, viewBefore, viewAfter)
427		assert.Equal(t, 2, l.offset)
428		// With virtual scrolling, rendered height should be viewport height (10)
429		assert.Equal(t, 10, lipgloss.Height(l.rendered))
430		golden.RequireEqual(t, []byte(l.View()))
431	})
432	t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
433		t.Parallel()
434		items := []Item{}
435		for i := range 30 {
436			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
437			items = append(items, item)
438		}
439		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
440		execCmd(l, l.Init())
441
442		execCmd(l, l.MoveUp(2))
443		viewBefore := l.View()
444		execCmd(l, l.PrependItem(NewSelectableItem("New")))
445		viewAfter := l.View()
446		assert.Equal(t, viewBefore, viewAfter)
447		assert.Equal(t, 2, l.offset)
448		// With virtual scrolling, rendered height should be viewport height (10)
449		assert.Equal(t, 10, lipgloss.Height(l.rendered))
450		golden.RequireEqual(t, []byte(l.View()))
451	})
452
453	t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
454		t.Parallel()
455		items := []Item{}
456		for i := range 30 {
457			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
458			content = strings.TrimSuffix(content, "\n")
459			item := NewSelectableItem(content)
460			items = append(items, item)
461		}
462		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
463		execCmd(l, l.Init())
464		execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
465
466		assert.Equal(t, 0, l.offset)
467		golden.RequireEqual(t, []byte(l.View()))
468	})
469
470	t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
471		t.Parallel()
472		items := []Item{}
473		for i := range 30 {
474			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
475			items = append(items, item)
476		}
477		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
478		execCmd(l, l.Init())
479
480		execCmd(l, l.MoveDown(2))
481		viewBefore := l.View()
482		execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
483		viewAfter := l.View()
484		assert.Equal(t, viewBefore, viewAfter)
485		assert.Equal(t, 5, l.offset)
486		// With virtual scrolling, rendered height should be viewport height (10)
487		assert.Equal(t, 10, lipgloss.Height(l.rendered))
488		golden.RequireEqual(t, []byte(l.View()))
489	})
490
491	t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
492		t.Parallel()
493		items := []Item{}
494		for i := range 30 {
495			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
496			items = append(items, item)
497		}
498		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
499		execCmd(l, l.Init())
500
501		execCmd(l, l.MoveDown(2))
502		viewBefore := l.View()
503		item := items[0]
504		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
505		viewAfter := l.View()
506		assert.Equal(t, viewBefore, viewAfter)
507		assert.Equal(t, 4, l.offset)
508		// With virtual scrolling, rendered height should be viewport height (10)
509		assert.Equal(t, 10, lipgloss.Height(l.rendered))
510		golden.RequireEqual(t, []byte(l.View()))
511	})
512
513	t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
514		t.Parallel()
515		items := []Item{}
516		items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
517		for i := range 30 {
518			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
519			items = append(items, item)
520		}
521		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
522		execCmd(l, l.Init())
523
524		execCmd(l, l.MoveDown(3))
525		viewBefore := l.View()
526		item := items[0]
527		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
528		viewAfter := l.View()
529		assert.Equal(t, viewBefore, viewAfter)
530		assert.Equal(t, 1, l.offset)
531		// With virtual scrolling, rendered height should be viewport height (10)
532		assert.Equal(t, 10, lipgloss.Height(l.rendered))
533		golden.RequireEqual(t, []byte(l.View()))
534	})
535
536	t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
537		t.Parallel()
538		items := []Item{}
539		for i := range 30 {
540			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
541			items = append(items, item)
542		}
543		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
544		execCmd(l, l.Init())
545
546		execCmd(l, l.MoveDown(2))
547		viewBefore := l.View()
548		item := items[29]
549		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
550		viewAfter := l.View()
551		assert.Equal(t, viewBefore, viewAfter)
552		assert.Equal(t, 2, l.offset)
553		// With virtual scrolling, rendered height should be viewport height (10)
554		assert.Equal(t, 10, lipgloss.Height(l.rendered))
555		golden.RequireEqual(t, []byte(l.View()))
556	})
557	t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
558		t.Parallel()
559		items := []Item{}
560		for i := range 30 {
561			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
562			items = append(items, item)
563		}
564		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
565		execCmd(l, l.Init())
566
567		execCmd(l, l.MoveDown(2))
568		viewBefore := l.View()
569		execCmd(l, l.AppendItem(NewSelectableItem("New")))
570		viewAfter := l.View()
571		assert.Equal(t, viewBefore, viewAfter)
572		assert.Equal(t, 2, l.offset)
573		// With virtual scrolling, rendered height should be viewport height (10)
574		assert.Equal(t, 10, lipgloss.Height(l.rendered))
575		golden.RequireEqual(t, []byte(l.View()))
576	})
577
578	t.Run("should scroll to top with SelectItemAbove and render 5 lines", func(t *testing.T) {
579		t.Parallel()
580		// Create 10 items
581		items := []Item{}
582		for i := range 10 {
583			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
584			items = append(items, item)
585		}
586		
587		// Create list with viewport of 5 lines, starting at the bottom
588		l := New(items, WithDirectionForward(), WithSize(5, 20), WithSelectedItem(items[9].ID())).(*list[Item])
589		execCmd(l, l.Init())
590		
591		// Verify we start at the bottom (item 9 selected)
592		assert.Equal(t, items[9].ID(), l.SelectedItemID())
593		
594		// Scroll to top one by one using SelectItemAbove
595		for i := 8; i >= 0; i-- {
596			execCmd(l, l.SelectItemAbove())
597			assert.Equal(t, items[i].ID(), l.SelectedItemID())
598		}
599		
600		// Now we should be at the first item
601		assert.Equal(t, items[0].ID(), l.SelectedItemID())
602		
603		// Verify the viewport is rendering exactly 5 lines
604		rendered := l.View()
605		lines := strings.Split(rendered, "\n")
606		assert.Equal(t, 5, len(lines), "Should render exactly 5 lines")
607		
608		// Verify the rendered content shows items 0-4
609		for i := 0; i < 5; i++ {
610			assert.Contains(t, lines[i], fmt.Sprintf("Item %d", i), "Line %d should contain Item %d", i, i)
611		}
612		
613		// Verify offset is at the top
614		assert.Equal(t, 0, l.offset)
615		
616		// Verify the viewport position
617		start, end := l.viewPosition()
618		assert.Equal(t, 0, start, "View should start at position 0")
619		assert.Equal(t, 4, end, "View should end at position 4")
620	})
621}
622
623type SelectableItem interface {
624	Item
625	layout.Focusable
626}
627
628type simpleItem struct {
629	width   int
630	content string
631	id      string
632}
633type selectableItem struct {
634	*simpleItem
635	focused bool
636}
637
638func NewSimpleItem(content string) *simpleItem {
639	return &simpleItem{
640		id:      uuid.NewString(),
641		width:   0,
642		content: content,
643	}
644}
645
646func NewSelectableItem(content string) SelectableItem {
647	return &selectableItem{
648		simpleItem: NewSimpleItem(content),
649		focused:    false,
650	}
651}
652
653func (s *simpleItem) ID() string {
654	return s.id
655}
656
657func (s *simpleItem) Init() tea.Cmd {
658	return nil
659}
660
661func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
662	return s, nil
663}
664
665func (s *simpleItem) View() string {
666	return lipgloss.NewStyle().Width(s.width).Render(s.content)
667}
668
669func (l *simpleItem) GetSize() (int, int) {
670	return l.width, 0
671}
672
673// SetSize implements Item.
674func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
675	s.width = width
676	return nil
677}
678
679func (s *selectableItem) View() string {
680	if s.focused {
681		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
682	}
683	return lipgloss.NewStyle().Width(s.width).Render(s.content)
684}
685
686// Blur implements SimpleItem.
687func (s *selectableItem) Blur() tea.Cmd {
688	s.focused = false
689	return nil
690}
691
692// Focus implements SimpleItem.
693func (s *selectableItem) Focus() tea.Cmd {
694	s.focused = true
695	return nil
696}
697
698// IsFocused implements SimpleItem.
699func (s *selectableItem) IsFocused() bool {
700	return s.focused
701}
702
703func execCmd(m tea.Model, cmd tea.Cmd) {
704	for cmd != nil {
705		msg := cmd()
706		m, cmd = m.Update(msg)
707	}
708}