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