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 the last item
30 assert.Equal(t, items[0].ID(), l.selectedItem)
31 assert.Equal(t, 0, l.offset)
32 require.Len(t, l.indexMap, 5)
33 require.Len(t, l.items, 5)
34 require.Len(t, l.renderedItems, 5)
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 assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
42 assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
43 }
44
45 golden.RequireEqual(t, []byte(l.View()))
46 })
47 t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
48 t.Parallel()
49 items := []Item{}
50 for i := range 5 {
51 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
52 items = append(items, item)
53 }
54 l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
55 execCmd(l, l.Init())
56
57 // should select the last item
58 assert.Equal(t, items[4].ID(), l.selectedItem)
59 assert.Equal(t, 0, l.offset)
60 require.Len(t, l.indexMap, 5)
61 require.Len(t, l.items, 5)
62 require.Len(t, l.renderedItems, 5)
63 assert.Equal(t, 5, lipgloss.Height(l.rendered))
64 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
65 start, end := l.viewPosition()
66 assert.Equal(t, 0, start)
67 assert.Equal(t, 4, end)
68 for i := range 5 {
69 assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
70 assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
71 }
72
73 golden.RequireEqual(t, []byte(l.View()))
74 })
75
76 t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
77 t.Parallel()
78 items := []Item{}
79 for i := range 30 {
80 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
81 items = append(items, item)
82 }
83 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
84 execCmd(l, l.Init())
85
86 // should select the last item
87 assert.Equal(t, items[0].ID(), l.selectedItem)
88 assert.Equal(t, 0, l.offset)
89 require.Len(t, l.indexMap, 30)
90 require.Len(t, l.items, 30)
91 require.Len(t, l.renderedItems, 30)
92 assert.Equal(t, 30, lipgloss.Height(l.rendered))
93 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
94 start, end := l.viewPosition()
95 assert.Equal(t, 0, start)
96 assert.Equal(t, 9, end)
97 for i := range 30 {
98 assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
99 assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
100 }
101
102 golden.RequireEqual(t, []byte(l.View()))
103 })
104 t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
105 t.Parallel()
106 items := []Item{}
107 for i := range 30 {
108 item := NewSelectableItem(fmt.Sprintf("Item %d", i))
109 items = append(items, item)
110 }
111 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
112 execCmd(l, l.Init())
113
114 // should select the last item
115 assert.Equal(t, items[29].ID(), l.selectedItem)
116 assert.Equal(t, 0, l.offset)
117 require.Len(t, l.indexMap, 30)
118 require.Len(t, l.items, 30)
119 require.Len(t, l.renderedItems, 30)
120 assert.Equal(t, 30, lipgloss.Height(l.rendered))
121 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
122 start, end := l.viewPosition()
123 assert.Equal(t, 20, start)
124 assert.Equal(t, 29, end)
125 for i := range 30 {
126 assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
127 assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
128 }
129
130 golden.RequireEqual(t, []byte(l.View()))
131 })
132
133 t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
134 t.Parallel()
135 items := []Item{}
136 for i := range 30 {
137 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
138 content = strings.TrimSuffix(content, "\n")
139 item := NewSelectableItem(content)
140 items = append(items, item)
141 }
142 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
143 execCmd(l, l.Init())
144
145 // should select the last item
146 assert.Equal(t, items[0].ID(), l.selectedItem)
147 assert.Equal(t, 0, l.offset)
148 require.Len(t, l.indexMap, 30)
149 require.Len(t, l.items, 30)
150 require.Len(t, l.renderedItems, 30)
151 expectedLines := 0
152 for i := range 30 {
153 expectedLines += (i + 1) * 1
154 }
155 assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
156 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
157 start, end := l.viewPosition()
158 assert.Equal(t, 0, start)
159 assert.Equal(t, 9, end)
160 currentPosition := 0
161 for i := range 30 {
162 rItem := l.renderedItems[items[i].ID()]
163 assert.Equal(t, currentPosition, rItem.start)
164 assert.Equal(t, currentPosition+i, rItem.end)
165 currentPosition += i + 1
166 }
167
168 golden.RequireEqual(t, []byte(l.View()))
169 })
170 t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
171 t.Parallel()
172 items := []Item{}
173 for i := range 30 {
174 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
175 content = strings.TrimSuffix(content, "\n")
176 item := NewSelectableItem(content)
177 items = append(items, item)
178 }
179 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
180 execCmd(l, l.Init())
181
182 // should select the last item
183 assert.Equal(t, items[29].ID(), l.selectedItem)
184 assert.Equal(t, 0, l.offset)
185 require.Len(t, l.indexMap, 30)
186 require.Len(t, l.items, 30)
187 require.Len(t, l.renderedItems, 30)
188 expectedLines := 0
189 for i := range 30 {
190 expectedLines += (i + 1) * 1
191 }
192 assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
193 assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
194 start, end := l.viewPosition()
195 assert.Equal(t, expectedLines-10, start)
196 assert.Equal(t, expectedLines-1, end)
197 currentPosition := 0
198 for i := range 30 {
199 rItem := l.renderedItems[items[i].ID()]
200 assert.Equal(t, currentPosition, rItem.start)
201 assert.Equal(t, currentPosition+i, rItem.end)
202 currentPosition += i + 1
203 }
204
205 golden.RequireEqual(t, []byte(l.View()))
206 })
207
208 t.Run("should go to selected item and center", func(t *testing.T) {
209 t.Parallel()
210 items := []Item{}
211 for i := range 30 {
212 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
213 content = strings.TrimSuffix(content, "\n")
214 item := NewSelectableItem(content)
215 items = append(items, item)
216 }
217 l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
218 execCmd(l, l.Init())
219
220 // should select the last item
221 assert.Equal(t, items[4].ID(), l.selectedItem)
222
223 golden.RequireEqual(t, []byte(l.View()))
224 })
225
226 t.Run("should go to selected item and center backwards", func(t *testing.T) {
227 t.Parallel()
228 items := []Item{}
229 for i := range 30 {
230 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
231 content = strings.TrimSuffix(content, "\n")
232 item := NewSelectableItem(content)
233 items = append(items, item)
234 }
235 l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
236 execCmd(l, l.Init())
237
238 // should select the last item
239 assert.Equal(t, items[4].ID(), l.selectedItem)
240
241 golden.RequireEqual(t, []byte(l.View()))
242 })
243
244 t.Run("should go to selected item at the beginning", func(t *testing.T) {
245 t.Parallel()
246 items := []Item{}
247 for i := range 30 {
248 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
249 content = strings.TrimSuffix(content, "\n")
250 item := NewSelectableItem(content)
251 items = append(items, item)
252 }
253 l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
254 execCmd(l, l.Init())
255
256 // should select the last item
257 assert.Equal(t, items[10].ID(), l.selectedItem)
258
259 golden.RequireEqual(t, []byte(l.View()))
260 })
261
262 t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
263 t.Parallel()
264 items := []Item{}
265 for i := range 30 {
266 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
267 content = strings.TrimSuffix(content, "\n")
268 item := NewSelectableItem(content)
269 items = append(items, item)
270 }
271 l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
272 execCmd(l, l.Init())
273
274 // should select the last item
275 assert.Equal(t, items[10].ID(), l.selectedItem)
276
277 golden.RequireEqual(t, []byte(l.View()))
278 })
279}
280
281func TestListMovement(t *testing.T) {
282 t.Parallel()
283 t.Run("should move viewport up", func(t *testing.T) {
284 t.Parallel()
285 items := []Item{}
286 for i := range 30 {
287 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
288 content = strings.TrimSuffix(content, "\n")
289 item := NewSelectableItem(content)
290 items = append(items, item)
291 }
292 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
293 execCmd(l, l.Init())
294
295 execCmd(l, l.MoveUp(25))
296
297 assert.Equal(t, 25, l.offset)
298 golden.RequireEqual(t, []byte(l.View()))
299 })
300 t.Run("should move viewport up and down", func(t *testing.T) {
301 t.Parallel()
302 items := []Item{}
303 for i := range 30 {
304 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
305 content = strings.TrimSuffix(content, "\n")
306 item := NewSelectableItem(content)
307 items = append(items, item)
308 }
309 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
310 execCmd(l, l.Init())
311
312 execCmd(l, l.MoveUp(25))
313 execCmd(l, l.MoveDown(25))
314
315 assert.Equal(t, 0, l.offset)
316 golden.RequireEqual(t, []byte(l.View()))
317 })
318
319 t.Run("should move viewport down", func(t *testing.T) {
320 t.Parallel()
321 items := []Item{}
322 for i := range 30 {
323 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
324 content = strings.TrimSuffix(content, "\n")
325 item := NewSelectableItem(content)
326 items = append(items, item)
327 }
328 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
329 execCmd(l, l.Init())
330
331 execCmd(l, l.MoveDown(25))
332
333 assert.Equal(t, 25, l.offset)
334 golden.RequireEqual(t, []byte(l.View()))
335 })
336 t.Run("should move viewport down and up", func(t *testing.T) {
337 t.Parallel()
338 items := []Item{}
339 for i := range 30 {
340 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
341 content = strings.TrimSuffix(content, "\n")
342 item := NewSelectableItem(content)
343 items = append(items, item)
344 }
345 l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
346 execCmd(l, l.Init())
347
348 execCmd(l, l.MoveDown(25))
349 execCmd(l, l.MoveUp(25))
350
351 assert.Equal(t, 0, l.offset)
352 golden.RequireEqual(t, []byte(l.View()))
353 })
354}
355
356type SelectableItem interface {
357 Item
358 layout.Focusable
359}
360
361type simpleItem struct {
362 width int
363 content string
364 id string
365}
366type selectableItem struct {
367 *simpleItem
368 focused bool
369}
370
371func NewSimpleItem(content string) *simpleItem {
372 return &simpleItem{
373 id: uuid.NewString(),
374 width: 0,
375 content: content,
376 }
377}
378
379func NewSelectableItem(content string) SelectableItem {
380 return &selectableItem{
381 simpleItem: NewSimpleItem(content),
382 focused: false,
383 }
384}
385
386func (s *simpleItem) ID() string {
387 return s.id
388}
389
390func (s *simpleItem) Init() tea.Cmd {
391 return nil
392}
393
394func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
395 return s, nil
396}
397
398func (s *simpleItem) View() string {
399 return lipgloss.NewStyle().Width(s.width).Render(s.content)
400}
401
402func (l *simpleItem) GetSize() (int, int) {
403 return l.width, 0
404}
405
406// SetSize implements Item.
407func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
408 s.width = width
409 return nil
410}
411
412func (s *selectableItem) View() string {
413 if s.focused {
414 return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
415 }
416 return lipgloss.NewStyle().Width(s.width).Render(s.content)
417}
418
419// Blur implements SimpleItem.
420func (s *selectableItem) Blur() tea.Cmd {
421 s.focused = false
422 return nil
423}
424
425// Focus implements SimpleItem.
426func (s *selectableItem) Focus() tea.Cmd {
427 s.focused = true
428 return nil
429}
430
431// IsFocused implements SimpleItem.
432func (s *selectableItem) IsFocused() bool {
433 return s.focused
434}
435
436func execCmd(m tea.Model, cmd tea.Cmd) {
437 for cmd != nil {
438 msg := cmd()
439 m, cmd = m.Update(msg)
440 }
441}