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}