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