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 type positionOffsetTest struct {
18 dir direction
19 test string
20 width int
21 height int
22 numItems int
23
24 moveUp int
25 moveDown int
26
27 expectedStart int
28 expectedEnd int
29 }
30 tests := []positionOffsetTest{
31 {
32 dir: Forward,
33 test: "should have correct position initially when forward",
34 moveUp: 0,
35 moveDown: 0,
36 width: 10,
37 height: 20,
38 numItems: 100,
39 expectedStart: 0,
40 expectedEnd: 19,
41 },
42 {
43 dir: Forward,
44 test: "should offset start and end by one when moving down by one",
45 moveUp: 0,
46 moveDown: 1,
47 width: 10,
48 height: 20,
49 numItems: 100,
50 expectedStart: 1,
51 expectedEnd: 20,
52 },
53 {
54 dir: Backward,
55 test: "should have correct position initially when backward",
56 moveUp: 0,
57 moveDown: 0,
58 width: 10,
59 height: 20,
60 numItems: 100,
61 expectedStart: 80,
62 expectedEnd: 99,
63 },
64 {
65 dir: Backward,
66 test: "should offset the start and end by one when moving up by one",
67 moveUp: 1,
68 moveDown: 0,
69 width: 10,
70 height: 20,
71 numItems: 100,
72 expectedStart: 79,
73 expectedEnd: 98,
74 },
75 }
76 for _, c := range tests {
77 t.Run(c.test, func(t *testing.T) {
78 items := []Item{}
79 for i := range c.numItems {
80 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
81 items = append(items, item)
82 }
83 l := New(items, WithDirection(c.dir)).(*list[Item])
84 l.SetSize(c.width, c.height)
85 cmd := l.Init()
86 if cmd != nil {
87 cmd()
88 }
89
90 if c.moveUp > 0 {
91 l.MoveUp(c.moveUp)
92 }
93 if c.moveDown > 0 {
94 l.MoveDown(c.moveDown)
95 }
96 start, end := l.viewPosition()
97 assert.Equal(t, c.expectedStart, start)
98 assert.Equal(t, c.expectedEnd, end)
99 })
100 }
101}
102
103func TestBackwardList(t *testing.T) {
104 t.Run("within height", func(t *testing.T) {
105 t.Parallel()
106 items := []Item{}
107 for i := range 5 {
108 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
109 items = append(items, item)
110 }
111 l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
112 l.SetSize(10, 20)
113 cmd := l.Init()
114 if cmd != nil {
115 cmd()
116 }
117
118 // should select the last item
119 assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
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 := NewSelectableItem(fmt.Sprintf("Item %d", i))
127 items = append(items, item)
128 }
129 l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
130 l.SetSize(10, 20)
131 cmd := l.Init()
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 items := []Item{}
141 for i := range 10 {
142 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
143 items = append(items, item)
144 }
145 l := New(items, WithDirection(Backward))
146 l.SetSize(10, 5)
147 cmd := l.Init()
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 items := []Item{}
157 for i := range 10 {
158 item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
159 items = append(items, item)
160 }
161 l := New(items, WithDirection(Backward))
162 l.SetSize(10, 5)
163 cmd := l.Init()
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 items := []Item{}
173 for i := range 10 {
174 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
175 items = append(items, item)
176 }
177 l := New(items, WithDirection(Backward))
178 l.SetSize(10, 5)
179 cmd := l.Init()
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 items := []Item{}
190 for i := range 10 {
191 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
192 items = append(items, item)
193 }
194 l := New(items, WithDirection(Backward)).(*list[Item])
195 l.SetSize(10, 5)
196 cmd := l.Init()
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 items := []Item{}
208 for i := range 10 {
209 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
210 items = append(items, item)
211 }
212 l := New(items, WithDirection(Backward))
213 l.SetSize(10, 5)
214 cmd := l.Init()
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 items := []Item{}
225 for i := range 10 {
226 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
227 items = append(items, item)
228 }
229 l := New(items, WithDirection(Backward)).(*list[Item])
230 l.SetSize(10, 5)
231 cmd := l.Init()
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 items := []Item{}
243 for i := range 10 {
244 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
245 items = append(items, item)
246 }
247 l := New(items, WithDirection(Backward)).(*list[Item])
248 l.SetSize(10, 5)
249 cmd := l.Init()
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 items := []Item{}
272 for i := range 10 {
273 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
274 items = append(items, item)
275 }
276 l := New(items, WithDirection(Backward)).(*list[Item])
277 l.SetSize(10, 5)
278 cmd := l.Init()
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 items := []Item{}
297 for i := range 5 {
298 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
299 items = append(items, item)
300 }
301 l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
302 l.SetSize(10, 20)
303 cmd := l.Init()
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 := NewSelectableItem(fmt.Sprintf("Item %d", i))
318 items = append(items, item)
319 }
320 l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
321 l.SetSize(10, 20)
322 cmd := l.Init()
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 items := []Item{}
332 for i := range 10 {
333 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
334 items = append(items, item)
335 }
336 l := New(items, WithDirection(Forward)).(*list[Item])
337 l.SetSize(10, 5)
338 cmd := l.Init()
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 items := []Item{}
348 for i := range 10 {
349 item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
350 items = append(items, item)
351 }
352 l := New(items, WithDirection(Forward)).(*list[Item])
353 l.SetSize(10, 5)
354 cmd := l.Init()
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 items := []Item{}
364 for i := range 10 {
365 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
366 items = append(items, item)
367 }
368 l := New(items, WithDirection(Forward)).(*list[Item])
369 l.SetSize(10, 5)
370 cmd := l.Init()
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 items := []Item{}
381 for i := range 10 {
382 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
383 items = append(items, item)
384 }
385 l := New(items, WithDirection(Forward)).(*list[Item])
386 l.SetSize(10, 5)
387 cmd := l.Init()
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 items := []Item{}
399 for i := range 10 {
400 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
401 items = append(items, item)
402 }
403 l := New(items, WithDirection(Forward)).(*list[Item])
404 l.SetSize(10, 5)
405 cmd := l.Init()
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 items := []Item{}
416 for i := range 10 {
417 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
418 items = append(items, item)
419 }
420 l := New(items, WithDirection(Forward)).(*list[Item])
421 l.SetSize(10, 5)
422 cmd := l.Init()
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 items := []Item{}
434 for i := range 10 {
435 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
436 items = append(items, item)
437 }
438 l := New(items, WithDirection(Forward)).(*list[Item])
439 l.SetSize(10, 5)
440 cmd := l.Init()
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 items := []Item{}
463 for i := range 10 {
464 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
465 items = append(items, item)
466 }
467 l := New(items, WithDirection(Forward)).(*list[Item])
468 l.SetSize(10, 5)
469 cmd := l.Init()
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 items := []Item{}
488 items = append(items, NewSimpleItem("None Selectable"))
489 for i := range 5 {
490 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
491 items = append(items, item)
492 }
493 l := New(items, WithDirection(Forward)).(*list[Item])
494 l.SetSize(100, 10)
495 cmd := l.Init()
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 t.Run("should select the correct item on startup", func(t *testing.T) {
504 t.Parallel()
505 items := []Item{}
506 for i := range 5 {
507 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
508 items = append(items, item)
509 }
510 l := New(items, WithDirection(Forward)).(*list[Item])
511 cmd := l.Init()
512 otherCmd := l.SetSelected(items[3].ID())
513 var wg sync.WaitGroup
514 if cmd != nil {
515 wg.Add(1)
516 go func() {
517 cmd()
518 wg.Done()
519 }()
520 }
521 if otherCmd != nil {
522 wg.Add(1)
523 go func() {
524 otherCmd()
525 wg.Done()
526 }()
527 }
528 wg.Wait()
529 l.SetSize(100, 10)
530 assert.Equal(t, items[3].ID(), l.selectedItem)
531 golden.RequireEqual(t, []byte(l.View()))
532 })
533 t.Run("should skip none selectable items in the middle", func(t *testing.T) {
534 t.Parallel()
535 items := []Item{}
536 item := NewSelectableItem("Item initial")
537 items = append(items, item)
538 items = append(items, NewSimpleItem("None Selectable"))
539 for i := range 5 {
540 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
541 items = append(items, item)
542 }
543 l := New(items, WithDirection(Forward)).(*list[Item])
544 l.SetSize(100, 10)
545 cmd := l.Init()
546 if cmd != nil {
547 cmd()
548 }
549 l.SelectItemBelow()
550 assert.Equal(t, items[2].ID(), l.selectedItem)
551 golden.RequireEqual(t, []byte(l.View()))
552 })
553}
554
555func TestListSetSelection(t *testing.T) {
556 t.Run("should move to the selected item", func(t *testing.T) {
557 t.Parallel()
558 items := []Item{}
559 for i := range 100 {
560 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
561 items = append(items, item)
562 }
563 l := New(items, WithDirection(Forward)).(*list[Item])
564 l.SetSize(100, 10)
565 cmd := l.Init()
566 if cmd != nil {
567 cmd()
568 }
569
570 cmd = l.SetSelected(items[52].ID())
571 if cmd != nil {
572 cmd()
573 }
574
575 assert.Equal(t, items[52].ID(), l.selectedItem)
576 golden.RequireEqual(t, []byte(l.View()))
577 })
578}
579
580type SelectableItem interface {
581 Item
582 layout.Focusable
583}
584
585type simpleItem struct {
586 width int
587 content string
588 id string
589}
590type selectableItem struct {
591 *simpleItem
592 focused bool
593}
594
595func NewSimpleItem(content string) *simpleItem {
596 return &simpleItem{
597 id: uuid.NewString(),
598 width: 0,
599 content: content,
600 }
601}
602
603func NewSelectableItem(content string) SelectableItem {
604 return &selectableItem{
605 simpleItem: NewSimpleItem(content),
606 focused: false,
607 }
608}
609
610func (s *simpleItem) ID() string {
611 return s.id
612}
613
614func (s *simpleItem) Init() tea.Cmd {
615 return nil
616}
617
618func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
619 return s, nil
620}
621
622func (s *simpleItem) View() string {
623 return lipgloss.NewStyle().Width(s.width).Render(s.content)
624}
625
626func (l *simpleItem) GetSize() (int, int) {
627 return l.width, 0
628}
629
630// SetSize implements Item.
631func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
632 s.width = width
633 return nil
634}
635
636func (s *selectableItem) View() string {
637 if s.focused {
638 return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
639 }
640 return lipgloss.NewStyle().Width(s.width).Render(s.content)
641}
642
643// Blur implements SimpleItem.
644func (s *selectableItem) Blur() tea.Cmd {
645 s.focused = false
646 return nil
647}
648
649// Focus implements SimpleItem.
650func (s *selectableItem) Focus() tea.Cmd {
651 s.focused = true
652 return nil
653}
654
655// IsFocused implements SimpleItem.
656func (s *selectableItem) IsFocused() bool {
657 return s.focused
658}