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 TestViewPosition(t *testing.T) {
18 t.Parallel()
19
20 t.Run("forward direction - normal scrolling", func(t *testing.T) {
21 t.Parallel()
22 items := []Item{createItem("test", 1)}
23 l := New(items, WithDirectionForward(), WithSize(20, 10)).(*list[Item])
24 l.virtualHeight = 50
25
26 // At the top
27 l.offset = 0
28 start, end := l.viewPosition()
29 assert.Equal(t, 0, start)
30 assert.Equal(t, 9, end)
31
32 // In the middle
33 l.offset = 20
34 start, end = l.viewPosition()
35 assert.Equal(t, 20, start)
36 assert.Equal(t, 29, end)
37
38 // Near the bottom
39 l.offset = 40
40 start, end = l.viewPosition()
41 assert.Equal(t, 40, start)
42 assert.Equal(t, 49, end)
43
44 // Past the maximum valid offset (should be clamped)
45 l.offset = 45
46 start, end = l.viewPosition()
47 assert.Equal(t, 40, start) // Clamped to max valid offset
48 assert.Equal(t, 49, end)
49
50 // Way past the end (should be clamped)
51 l.offset = 100
52 start, end = l.viewPosition()
53 assert.Equal(t, 40, start) // Clamped to max valid offset
54 assert.Equal(t, 49, end)
55 })
56
57 t.Run("forward direction - edge case with exact fit", func(t *testing.T) {
58 t.Parallel()
59 items := []Item{createItem("test", 1)}
60 l := New(items, WithDirectionForward(), WithSize(20, 10)).(*list[Item])
61 l.virtualHeight = 10
62
63 l.offset = 0
64 start, end := l.viewPosition()
65 assert.Equal(t, 0, start)
66 assert.Equal(t, 9, end)
67
68 // Offset beyond valid range should be clamped
69 l.offset = 5
70 start, end = l.viewPosition()
71 assert.Equal(t, 0, start)
72 assert.Equal(t, 9, end)
73 })
74
75 t.Run("forward direction - content smaller than viewport", func(t *testing.T) {
76 t.Parallel()
77 items := []Item{createItem("test", 1)}
78 l := New(items, WithDirectionForward(), WithSize(20, 10)).(*list[Item])
79 l.virtualHeight = 5
80
81 l.offset = 0
82 start, end := l.viewPosition()
83 assert.Equal(t, 0, start)
84 assert.Equal(t, 4, end)
85
86 // Any offset should be clamped to 0
87 l.offset = 10
88 start, end = l.viewPosition()
89 assert.Equal(t, 0, start)
90 assert.Equal(t, 4, end)
91 })
92
93 t.Run("backward direction - normal scrolling", func(t *testing.T) {
94 t.Parallel()
95 items := []Item{createItem("test", 1)}
96 l := New(items, WithDirectionBackward(), WithSize(20, 10)).(*list[Item])
97 l.virtualHeight = 50
98
99 // At the bottom (offset 0 in backward mode)
100 l.offset = 0
101 start, end := l.viewPosition()
102 assert.Equal(t, 40, start)
103 assert.Equal(t, 49, end)
104
105 // In the middle
106 l.offset = 20
107 start, end = l.viewPosition()
108 assert.Equal(t, 20, start)
109 assert.Equal(t, 29, end)
110
111 // Near the top
112 l.offset = 40
113 start, end = l.viewPosition()
114 assert.Equal(t, 0, start)
115 assert.Equal(t, 9, end)
116
117 // Past the maximum valid offset (should be clamped)
118 l.offset = 45
119 start, end = l.viewPosition()
120 assert.Equal(t, 0, start)
121 assert.Equal(t, 9, end)
122 })
123
124 t.Run("backward direction - edge cases", func(t *testing.T) {
125 t.Parallel()
126 items := []Item{createItem("test", 1)}
127 l := New(items, WithDirectionBackward(), WithSize(20, 10)).(*list[Item])
128 l.virtualHeight = 5
129
130 // Content smaller than viewport
131 l.offset = 0
132 start, end := l.viewPosition()
133 assert.Equal(t, 0, start)
134 assert.Equal(t, 4, end)
135
136 // Any offset should show all content
137 l.offset = 10
138 start, end = l.viewPosition()
139 assert.Equal(t, 0, start)
140 assert.Equal(t, 4, end)
141 })
142}
143
144// Helper to create a test item with specific height
145func createItem(id string, height int) Item {
146 content := strings.Repeat(id+"\n", height)
147 if height > 0 {
148 content = strings.TrimSuffix(content, "\n")
149 }
150 item := &testItem{
151 id: id,
152 content: content,
153 }
154 return item
155}
156
157func TestRenderVirtualScrolling(t *testing.T) {
158 t.Parallel()
159
160 t.Run("should handle partially visible items at top", func(t *testing.T) {
161 t.Parallel()
162 items := []Item{
163 createItem("A", 1),
164 createItem("B", 5),
165 createItem("C", 1),
166 createItem("D", 3),
167 }
168
169 l := New(items, WithDirectionForward(), WithSize(20, 3)).(*list[Item])
170 execCmd(l, l.Init())
171
172 // Position B partially visible at top
173 l.offset = 2 // Start viewing from line 2 (middle of B)
174 l.calculateItemPositions()
175
176 // Item positions: A(0-0), B(1-5), C(6-6), D(7-9)
177 // Viewport: lines 2-4 (height=3)
178 // Should show: lines 2-4 of B (3 lines from B)
179
180 rendered := l.renderVirtualScrolling()
181 lines := strings.Split(rendered, "\n")
182 assert.Equal(t, 3, len(lines))
183 assert.Equal(t, "B", lines[0])
184 assert.Equal(t, "B", lines[1])
185 assert.Equal(t, "B", lines[2])
186 })
187
188 t.Run("should handle gaps between items correctly", func(t *testing.T) {
189 t.Parallel()
190 items := []Item{
191 createItem("A", 1),
192 createItem("B", 1),
193 createItem("C", 1),
194 }
195
196 l := New(items, WithDirectionForward(), WithSize(20, 5), WithGap(1)).(*list[Item])
197 execCmd(l, l.Init())
198
199 // Item positions: A(0-0), gap(1), B(2-2), gap(3), C(4-4)
200 // Viewport: lines 0-4 (height=5)
201 // Should show all items with gaps
202
203 rendered := l.renderVirtualScrolling()
204 lines := strings.Split(rendered, "\n")
205 assert.Equal(t, 5, len(lines))
206 assert.Equal(t, "A", lines[0])
207 assert.Equal(t, "", lines[1]) // gap
208 assert.Equal(t, "B", lines[2])
209 assert.Equal(t, "", lines[3]) // gap
210 assert.Equal(t, "C", lines[4])
211 })
212
213 t.Run("should not show empty lines when scrolled to bottom", func(t *testing.T) {
214 t.Parallel()
215 items := []Item{
216 createItem("A", 2),
217 createItem("B", 2),
218 createItem("C", 2),
219 createItem("D", 2),
220 createItem("E", 2),
221 }
222
223 l := New(items, WithDirectionForward(), WithSize(20, 4)).(*list[Item])
224 execCmd(l, l.Init())
225 l.calculateItemPositions()
226
227 // Total height: 10 lines (5 items * 2 lines each)
228 // Scroll to show last 4 lines
229 l.offset = 6
230
231 rendered := l.renderVirtualScrolling()
232 lines := strings.Split(rendered, "\n")
233 assert.Equal(t, 4, len(lines))
234 // Should show last 2 items completely
235 assert.Equal(t, "D", lines[0])
236 assert.Equal(t, "D", lines[1])
237 assert.Equal(t, "E", lines[2])
238 assert.Equal(t, "E", lines[3])
239 })
240
241 t.Run("should handle offset at maximum boundary", func(t *testing.T) {
242 t.Parallel()
243 items := []Item{
244 createItem("A", 3),
245 createItem("B", 3),
246 createItem("C", 3),
247 createItem("D", 3),
248 }
249
250 l := New(items, WithDirectionForward(), WithSize(20, 5)).(*list[Item])
251 execCmd(l, l.Init())
252 l.calculateItemPositions()
253
254 // Total height: 12 lines
255 // Max valid offset: 12 - 5 = 7
256 l.offset = 7
257
258 rendered := l.renderVirtualScrolling()
259 lines := strings.Split(rendered, "\n")
260 assert.Equal(t, 5, len(lines))
261 // Should show from line 7 to 11
262 assert.Contains(t, rendered, "C")
263 assert.Contains(t, rendered, "D")
264
265 // Try setting offset beyond max - should be clamped
266 l.offset = 20
267 rendered = l.renderVirtualScrolling()
268 lines = strings.Split(rendered, "\n")
269 assert.Equal(t, 5, len(lines))
270 // Should still show the same content as offset=7
271 assert.Contains(t, rendered, "C")
272 assert.Contains(t, rendered, "D")
273 })
274}
275
276// testItem is a simple implementation of Item for testing
277type testItem struct {
278 id string
279 content string
280}
281
282func (t *testItem) ID() string {
283 return t.id
284}
285
286func (t *testItem) View() string {
287 return t.content
288}
289
290func (t *testItem) Selectable() bool {
291 return true
292}
293
294func (t *testItem) Height() int {
295 return lipgloss.Height(t.content)
296}
297
298func (t *testItem) Init() tea.Cmd {
299 return nil
300}
301
302func (t *testItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
303 return t, nil
304}
305
306func (t *testItem) SetSize(width, height int) tea.Cmd {
307 return nil
308}
309
310func (t *testItem) GetSize() (int, int) {
311 return 0, lipgloss.Height(t.content)
312}
313
314func (t *testItem) SetFocused(focused bool) tea.Cmd {
315 return nil
316}
317
318func (t *testItem) Focused() bool {
319 return false
320}
321
322func TestList(t *testing.T) {
323 t.Parallel()
324 t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
325 t.Parallel()
326 items := []Item{}
327 for i := range 5 {
328 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
329 items = append(items, item)
330 }
331 l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
332 execCmd(l, l.Init())
333
334 // should select item 10
335 assert.Equal(t, items[0].ID(), l.SelectedItemID())
336 assert.Equal(t, 0, l.offset)
337 require.Equal(t, 5, l.indexMap.Len())
338 require.Equal(t, 5, l.items.Len())
339 require.Equal(t, 5, len(l.itemPositions))
340 assert.Equal(t, 5, lipgloss.Height(l.rendered))
341 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
342 start, end := l.viewPosition()
343 assert.Equal(t, 0, start)
344 assert.Equal(t, 4, end)
345 for i := range 5 {
346 item := l.itemPositions[i]
347 assert.Equal(t, i, item.start)
348 assert.Equal(t, i, item.end)
349 }
350
351 golden.RequireEqual(t, []byte(l.View()))
352 })
353 t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
354 t.Parallel()
355 items := []Item{}
356 for i := range 5 {
357 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
358 items = append(items, item)
359 }
360 l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
361 execCmd(l, l.Init())
362
363 // should select item 10
364 assert.Equal(t, items[4].ID(), l.SelectedItemID())
365 assert.Equal(t, 0, l.offset)
366 require.Equal(t, 5, l.indexMap.Len())
367 require.Equal(t, 5, l.items.Len())
368 require.Equal(t, 5, len(l.itemPositions))
369 assert.Equal(t, 5, lipgloss.Height(l.rendered))
370 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
371 start, end := l.viewPosition()
372 assert.Equal(t, 0, start)
373 assert.Equal(t, 4, end)
374 for i := range 5 {
375 item := l.itemPositions[i]
376 assert.Equal(t, i, item.start)
377 assert.Equal(t, i, item.end)
378 }
379
380 golden.RequireEqual(t, []byte(l.View()))
381 })
382
383 t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
384 t.Parallel()
385 items := []Item{}
386 for i := range 30 {
387 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
388 items = append(items, item)
389 }
390 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
391 execCmd(l, l.Init())
392
393 // should select item 10
394 assert.Equal(t, items[0].ID(), l.SelectedItemID())
395 assert.Equal(t, 0, l.offset)
396 require.Equal(t, 30, l.indexMap.Len())
397 require.Equal(t, 30, l.items.Len())
398 require.Equal(t, 30, len(l.itemPositions))
399 // With virtual scrolling, rendered height should be viewport height (10)
400 assert.Equal(t, 10, lipgloss.Height(l.rendered))
401 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
402 start, end := l.viewPosition()
403 assert.Equal(t, 0, start)
404 assert.Equal(t, 9, end)
405 for i := range 30 {
406 item := l.itemPositions[i]
407 assert.Equal(t, i, item.start)
408 assert.Equal(t, i, item.end)
409 }
410
411 golden.RequireEqual(t, []byte(l.View()))
412 })
413 t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
414 t.Parallel()
415 items := []Item{}
416 for i := range 30 {
417 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
418 items = append(items, item)
419 }
420 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
421 execCmd(l, l.Init())
422
423 // should select item 10
424 assert.Equal(t, items[29].ID(), l.SelectedItemID())
425 assert.Equal(t, 0, l.offset)
426 require.Equal(t, 30, l.indexMap.Len())
427 require.Equal(t, 30, l.items.Len())
428 require.Equal(t, 30, len(l.itemPositions))
429 // With virtual scrolling, rendered height should be viewport height (10)
430 assert.Equal(t, 10, lipgloss.Height(l.rendered))
431 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
432 start, end := l.viewPosition()
433 assert.Equal(t, 20, start)
434 assert.Equal(t, 29, end)
435 for i := range 30 {
436 item := l.itemPositions[i]
437 assert.Equal(t, i, item.start)
438 assert.Equal(t, i, item.end)
439 }
440
441 golden.RequireEqual(t, []byte(l.View()))
442 })
443
444 t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
445 t.Parallel()
446 items := []Item{}
447 for i := range 30 {
448 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
449 content = strings.TrimSuffix(content, "\n")
450 item := NewSelectableItem(content)
451 items = append(items, item)
452 }
453 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
454 execCmd(l, l.Init())
455
456 // should select item 10
457 assert.Equal(t, items[0].ID(), l.SelectedItemID())
458 assert.Equal(t, 0, l.offset)
459 require.Equal(t, 30, l.indexMap.Len())
460 require.Equal(t, 30, l.items.Len())
461 require.Equal(t, 30, len(l.itemPositions))
462 expectedLines := 0
463 for i := range 30 {
464 expectedLines += (i + 1) * 1
465 }
466 // With virtual scrolling, rendered height should be viewport height (10)
467 assert.Equal(t, 10, lipgloss.Height(l.rendered))
468 if len(l.rendered) > 0 {
469 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
470 }
471 start, end := l.viewPosition()
472 assert.Equal(t, 0, start)
473 assert.Equal(t, 9, end)
474 currentPosition := 0
475 for i := range 30 {
476 rItem := l.itemPositions[i]
477 assert.Equal(t, currentPosition, rItem.start)
478 assert.Equal(t, currentPosition+i, rItem.end)
479 currentPosition += i + 1
480 }
481
482 golden.RequireEqual(t, []byte(l.View()))
483 })
484 t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
485 t.Parallel()
486 items := []Item{}
487 for i := range 30 {
488 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
489 content = strings.TrimSuffix(content, "\n")
490 item := NewSelectableItem(content)
491 items = append(items, item)
492 }
493 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
494 execCmd(l, l.Init())
495
496 // should select item 10
497 assert.Equal(t, items[29].ID(), l.SelectedItemID())
498 assert.Equal(t, 0, l.offset)
499 require.Equal(t, 30, l.indexMap.Len())
500 require.Equal(t, 30, l.items.Len())
501 require.Equal(t, 30, len(l.itemPositions))
502 expectedLines := 0
503 for i := range 30 {
504 expectedLines += (i + 1) * 1
505 }
506 // With virtual scrolling, rendered height should be viewport height (10)
507 assert.Equal(t, 10, lipgloss.Height(l.rendered))
508 if len(l.rendered) > 0 {
509 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
510 }
511 start, end := l.viewPosition()
512 assert.Equal(t, expectedLines-10, start)
513 assert.Equal(t, expectedLines-1, end)
514 currentPosition := 0
515 for i := range 30 {
516 rItem := l.itemPositions[i]
517 assert.Equal(t, currentPosition, rItem.start)
518 assert.Equal(t, currentPosition+i, rItem.end)
519 currentPosition += i + 1
520 }
521
522 golden.RequireEqual(t, []byte(l.View()))
523 })
524
525 t.Run("should go to selected item at the beginning", func(t *testing.T) {
526 t.Parallel()
527 items := []Item{}
528 for i := range 30 {
529 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
530 content = strings.TrimSuffix(content, "\n")
531 item := NewSelectableItem(content)
532 items = append(items, item)
533 }
534 l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
535 execCmd(l, l.Init())
536
537 // should select item 10
538 assert.Equal(t, items[10].ID(), l.SelectedItemID())
539
540 golden.RequireEqual(t, []byte(l.View()))
541 })
542
543 t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
544 t.Parallel()
545 items := []Item{}
546 for i := range 30 {
547 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
548 content = strings.TrimSuffix(content, "\n")
549 item := NewSelectableItem(content)
550 items = append(items, item)
551 }
552 l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
553 execCmd(l, l.Init())
554
555 // should select item 10
556 assert.Equal(t, items[10].ID(), l.SelectedItemID())
557
558 golden.RequireEqual(t, []byte(l.View()))
559 })
560}
561
562func TestListMovement(t *testing.T) {
563 t.Parallel()
564 t.Run("should move viewport up", func(t *testing.T) {
565 t.Parallel()
566 items := []Item{}
567 for i := range 30 {
568 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
569 content = strings.TrimSuffix(content, "\n")
570 item := NewSelectableItem(content)
571 items = append(items, item)
572 }
573 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
574 execCmd(l, l.Init())
575
576 execCmd(l, l.MoveUp(25))
577
578 assert.Equal(t, 25, l.offset)
579 golden.RequireEqual(t, []byte(l.View()))
580 })
581 t.Run("should move viewport up and down", func(t *testing.T) {
582 t.Parallel()
583 items := []Item{}
584 for i := range 30 {
585 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
586 content = strings.TrimSuffix(content, "\n")
587 item := NewSelectableItem(content)
588 items = append(items, item)
589 }
590 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
591 execCmd(l, l.Init())
592
593 execCmd(l, l.MoveUp(25))
594 execCmd(l, l.MoveDown(25))
595
596 assert.Equal(t, 0, l.offset)
597 golden.RequireEqual(t, []byte(l.View()))
598 })
599
600 t.Run("should move viewport down", func(t *testing.T) {
601 t.Parallel()
602 items := []Item{}
603 for i := range 30 {
604 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
605 content = strings.TrimSuffix(content, "\n")
606 item := NewSelectableItem(content)
607 items = append(items, item)
608 }
609 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
610 execCmd(l, l.Init())
611
612 execCmd(l, l.MoveDown(25))
613
614 assert.Equal(t, 25, l.offset)
615 golden.RequireEqual(t, []byte(l.View()))
616 })
617 t.Run("should move viewport down and up", func(t *testing.T) {
618 t.Parallel()
619 items := []Item{}
620 for i := range 30 {
621 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
622 content = strings.TrimSuffix(content, "\n")
623 item := NewSelectableItem(content)
624 items = append(items, item)
625 }
626 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
627 execCmd(l, l.Init())
628
629 execCmd(l, l.MoveDown(25))
630 execCmd(l, l.MoveUp(25))
631
632 assert.Equal(t, 0, l.offset)
633 golden.RequireEqual(t, []byte(l.View()))
634 })
635
636 t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
637 t.Parallel()
638 items := []Item{}
639 for i := range 30 {
640 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
641 content = strings.TrimSuffix(content, "\n")
642 item := NewSelectableItem(content)
643 items = append(items, item)
644 }
645 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
646 execCmd(l, l.Init())
647 execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
648
649 assert.Equal(t, 0, l.offset)
650 golden.RequireEqual(t, []byte(l.View()))
651 })
652
653 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) {
654 t.Parallel()
655 items := []Item{}
656 for i := range 30 {
657 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
658 items = append(items, item)
659 }
660 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
661 execCmd(l, l.Init())
662
663 execCmd(l, l.MoveUp(2))
664 viewBefore := l.View()
665 execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
666 viewAfter := l.View()
667 assert.Equal(t, viewBefore, viewAfter)
668 assert.Equal(t, 5, l.offset)
669 // With virtual scrolling, rendered height should be viewport height (10)
670 assert.Equal(t, 10, lipgloss.Height(l.rendered))
671 golden.RequireEqual(t, []byte(l.View()))
672 })
673 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) {
674 t.Parallel()
675 items := []Item{}
676 for i := range 30 {
677 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
678 items = append(items, item)
679 }
680 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
681 execCmd(l, l.Init())
682
683 execCmd(l, l.MoveUp(2))
684 viewBefore := l.View()
685 item := items[29]
686 execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
687 viewAfter := l.View()
688 assert.Equal(t, viewBefore, viewAfter)
689 assert.Equal(t, 4, l.offset)
690 // With virtual scrolling, rendered height should be viewport height (10)
691 assert.Equal(t, 10, lipgloss.Height(l.rendered))
692 golden.RequireEqual(t, []byte(l.View()))
693 })
694 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) {
695 t.Parallel()
696 items := []Item{}
697 for i := range 30 {
698 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
699 items = append(items, item)
700 }
701 items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
702 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
703 execCmd(l, l.Init())
704
705 execCmd(l, l.MoveUp(2))
706 viewBefore := l.View()
707 item := items[30]
708 execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
709 viewAfter := l.View()
710 assert.Equal(t, viewBefore, viewAfter)
711 assert.Equal(t, 0, l.offset)
712 // With virtual scrolling, rendered height should be viewport height (10)
713 assert.Equal(t, 10, lipgloss.Height(l.rendered))
714 golden.RequireEqual(t, []byte(l.View()))
715 })
716 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) {
717 t.Parallel()
718 items := []Item{}
719 for i := range 30 {
720 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
721 items = append(items, item)
722 }
723 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
724 execCmd(l, l.Init())
725
726 execCmd(l, l.MoveUp(2))
727 viewBefore := l.View()
728 item := items[1]
729 execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
730 viewAfter := l.View()
731 assert.Equal(t, viewBefore, viewAfter)
732 assert.Equal(t, 2, l.offset)
733 // With virtual scrolling, rendered height should be viewport height (10)
734 assert.Equal(t, 10, lipgloss.Height(l.rendered))
735 golden.RequireEqual(t, []byte(l.View()))
736 })
737 t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
738 t.Parallel()
739 items := []Item{}
740 for i := range 30 {
741 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
742 items = append(items, item)
743 }
744 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
745 execCmd(l, l.Init())
746
747 execCmd(l, l.MoveUp(2))
748 viewBefore := l.View()
749 execCmd(l, l.PrependItem(NewSelectableItem("New")))
750 viewAfter := l.View()
751 assert.Equal(t, viewBefore, viewAfter)
752 assert.Equal(t, 2, l.offset)
753 // With virtual scrolling, rendered height should be viewport height (10)
754 assert.Equal(t, 10, lipgloss.Height(l.rendered))
755 golden.RequireEqual(t, []byte(l.View()))
756 })
757
758 t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
759 t.Parallel()
760 items := []Item{}
761 for i := range 30 {
762 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
763 content = strings.TrimSuffix(content, "\n")
764 item := NewSelectableItem(content)
765 items = append(items, item)
766 }
767 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
768 execCmd(l, l.Init())
769 execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
770
771 assert.Equal(t, 0, l.offset)
772 golden.RequireEqual(t, []byte(l.View()))
773 })
774
775 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) {
776 t.Parallel()
777 items := []Item{}
778 for i := range 30 {
779 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
780 items = append(items, item)
781 }
782 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
783 execCmd(l, l.Init())
784
785 execCmd(l, l.MoveDown(2))
786 viewBefore := l.View()
787 execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
788 viewAfter := l.View()
789 assert.Equal(t, viewBefore, viewAfter)
790 assert.Equal(t, 5, l.offset)
791 // With virtual scrolling, rendered height should be viewport height (10)
792 assert.Equal(t, 10, lipgloss.Height(l.rendered))
793 golden.RequireEqual(t, []byte(l.View()))
794 })
795
796 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) {
797 t.Parallel()
798 items := []Item{}
799 for i := range 30 {
800 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
801 items = append(items, item)
802 }
803 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
804 execCmd(l, l.Init())
805
806 execCmd(l, l.MoveDown(2))
807 viewBefore := l.View()
808 item := items[0]
809 execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
810 viewAfter := l.View()
811 assert.Equal(t, viewBefore, viewAfter)
812 assert.Equal(t, 4, l.offset)
813 // With virtual scrolling, rendered height should be viewport height (10)
814 assert.Equal(t, 10, lipgloss.Height(l.rendered))
815 golden.RequireEqual(t, []byte(l.View()))
816 })
817
818 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) {
819 t.Parallel()
820 items := []Item{}
821 items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
822 for i := range 30 {
823 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
824 items = append(items, item)
825 }
826 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
827 execCmd(l, l.Init())
828
829 execCmd(l, l.MoveDown(3))
830 viewBefore := l.View()
831 item := items[0]
832 execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
833 viewAfter := l.View()
834 assert.Equal(t, viewBefore, viewAfter)
835 assert.Equal(t, 1, l.offset)
836 // With virtual scrolling, rendered height should be viewport height (10)
837 assert.Equal(t, 10, lipgloss.Height(l.rendered))
838 golden.RequireEqual(t, []byte(l.View()))
839 })
840
841 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) {
842 t.Parallel()
843 items := []Item{}
844 for i := range 30 {
845 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
846 items = append(items, item)
847 }
848 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
849 execCmd(l, l.Init())
850
851 execCmd(l, l.MoveDown(2))
852 viewBefore := l.View()
853 item := items[29]
854 execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
855 viewAfter := l.View()
856 assert.Equal(t, viewBefore, viewAfter)
857 assert.Equal(t, 2, l.offset)
858 // With virtual scrolling, rendered height should be viewport height (10)
859 assert.Equal(t, 10, lipgloss.Height(l.rendered))
860 golden.RequireEqual(t, []byte(l.View()))
861 })
862 t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
863 t.Parallel()
864 items := []Item{}
865 for i := range 30 {
866 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
867 items = append(items, item)
868 }
869 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
870 execCmd(l, l.Init())
871
872 execCmd(l, l.MoveDown(2))
873 viewBefore := l.View()
874 execCmd(l, l.AppendItem(NewSelectableItem("New")))
875 viewAfter := l.View()
876 assert.Equal(t, viewBefore, viewAfter)
877 assert.Equal(t, 2, l.offset)
878 // With virtual scrolling, rendered height should be viewport height (10)
879 assert.Equal(t, 10, lipgloss.Height(l.rendered))
880 golden.RequireEqual(t, []byte(l.View()))
881 })
882
883 t.Run("should scroll to top with SelectItemAbove and render 5 lines", func(t *testing.T) {
884 t.Parallel()
885 // Create 10 items
886 items := []Item{}
887 for i := range 10 {
888 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
889 items = append(items, item)
890 }
891
892 // Create list with viewport of 5 lines height and 20 width, starting at the bottom (index 9)
893 l := New(items, WithDirectionForward(), WithSize(20, 5), WithSelectedIndex(9)).(*list[Item])
894 execCmd(l, l.Init())
895
896 // Verify we start at the bottom (item 9 selected)
897 assert.Equal(t, items[9].ID(), l.SelectedItemID())
898 assert.Equal(t, 9, l.SelectedItemIndex())
899
900 // Scroll to top one by one using SelectItemAbove
901 for i := 8; i >= 0; i-- {
902 execCmd(l, l.SelectItemAbove())
903 assert.Equal(t, items[i].ID(), l.SelectedItemID())
904 assert.Equal(t, i, l.SelectedItemIndex())
905 }
906
907 // Now we should be at the first item
908 assert.Equal(t, items[0].ID(), l.SelectedItemID())
909 assert.Equal(t, 0, l.SelectedItemIndex())
910
911 // Verify the viewport is rendering exactly 5 lines
912 rendered := l.View()
913
914 // Check the height using lipgloss
915 assert.Equal(t, 5, lipgloss.Height(rendered), "Should render exactly 5 lines")
916
917 // Verify offset is at the top
918 assert.Equal(t, 0, l.offset)
919
920 // Verify the viewport position
921 start, end := l.viewPosition()
922 assert.Equal(t, 0, start, "View should start at position 0")
923 assert.Equal(t, 4, end, "View should end at position 4")
924 })
925}
926
927type SelectableItem interface {
928 Item
929 layout.Focusable
930}
931
932type simpleItem struct {
933 width int
934 content string
935 id string
936}
937type selectableItem struct {
938 *simpleItem
939 focused bool
940}
941
942func NewSimpleItem(content string) *simpleItem {
943 return &simpleItem{
944 id: uuid.NewString(),
945 width: 0,
946 content: content,
947 }
948}
949
950func NewSelectableItem(content string) SelectableItem {
951 return &selectableItem{
952 simpleItem: NewSimpleItem(content),
953 focused: false,
954 }
955}
956
957func (s *simpleItem) ID() string {
958 return s.id
959}
960
961func (s *simpleItem) Init() tea.Cmd {
962 return nil
963}
964
965func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
966 return s, nil
967}
968
969func (s *simpleItem) View() string {
970 return lipgloss.NewStyle().Width(s.width).Render(s.content)
971}
972
973func (l *simpleItem) GetSize() (int, int) {
974 return l.width, 0
975}
976
977// SetSize implements Item.
978func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
979 s.width = width
980 return nil
981}
982
983func (s *selectableItem) View() string {
984 if s.focused {
985 return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
986 }
987 return lipgloss.NewStyle().Width(s.width).Render(s.content)
988}
989
990// Blur implements SimpleItem.
991func (s *selectableItem) Blur() tea.Cmd {
992 s.focused = false
993 return nil
994}
995
996// Focus implements SimpleItem.
997func (s *selectableItem) Focus() tea.Cmd {
998 s.focused = true
999 return nil
1000}
1001
1002// IsFocused implements SimpleItem.
1003func (s *selectableItem) IsFocused() bool {
1004 return s.focused
1005}
1006
1007func execCmd(m tea.Model, cmd tea.Cmd) {
1008 for cmd != nil {
1009 msg := cmd()
1010 m, cmd = m.Update(msg)
1011 }
1012}