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 at the beginning", 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[10].ID())).(*list[Item])
218 execCmd(l, l.Init())
219
220 // should select the last item
221 assert.Equal(t, items[10].ID(), l.selectedItem)
222
223 golden.RequireEqual(t, []byte(l.View()))
224 })
225
226 t.Run("should go to selected item at the beginning 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[10].ID())).(*list[Item])
236 execCmd(l, l.Init())
237
238 // should select the last item
239 assert.Equal(t, items[10].ID(), l.selectedItem)
240
241 golden.RequireEqual(t, []byte(l.View()))
242 })
243}
244
245func TestListMovement(t *testing.T) {
246 t.Parallel()
247 t.Run("should move viewport up", func(t *testing.T) {
248 t.Parallel()
249 items := []Item{}
250 for i := range 30 {
251 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
252 content = strings.TrimSuffix(content, "\n")
253 item := NewSelectableItem(content)
254 items = append(items, item)
255 }
256 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
257 execCmd(l, l.Init())
258
259 execCmd(l, l.MoveUp(25))
260
261 assert.Equal(t, 25, l.offset)
262 golden.RequireEqual(t, []byte(l.View()))
263 })
264 t.Run("should move viewport up and down", func(t *testing.T) {
265 t.Parallel()
266 items := []Item{}
267 for i := range 30 {
268 content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
269 content = strings.TrimSuffix(content, "\n")
270 item := NewSelectableItem(content)
271 items = append(items, item)
272 }
273 l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
274 execCmd(l, l.Init())
275
276 execCmd(l, l.MoveUp(25))
277 execCmd(l, l.MoveDown(25))
278
279 assert.Equal(t, 0, l.offset)
280 golden.RequireEqual(t, []byte(l.View()))
281 })
282
283 t.Run("should move viewport down", 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, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
293 execCmd(l, l.Init())
294
295 execCmd(l, l.MoveDown(25))
296
297 assert.Equal(t, 25, l.offset)
298 golden.RequireEqual(t, []byte(l.View()))
299 })
300 t.Run("should move viewport down and up", 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, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
310 execCmd(l, l.Init())
311
312 execCmd(l, l.MoveDown(25))
313 execCmd(l, l.MoveUp(25))
314
315 assert.Equal(t, 0, l.offset)
316 golden.RequireEqual(t, []byte(l.View()))
317 })
318}
319
320type SelectableItem interface {
321 Item
322 layout.Focusable
323}
324
325type simpleItem struct {
326 width int
327 content string
328 id string
329}
330type selectableItem struct {
331 *simpleItem
332 focused bool
333}
334
335func NewSimpleItem(content string) *simpleItem {
336 return &simpleItem{
337 id: uuid.NewString(),
338 width: 0,
339 content: content,
340 }
341}
342
343func NewSelectableItem(content string) SelectableItem {
344 return &selectableItem{
345 simpleItem: NewSimpleItem(content),
346 focused: false,
347 }
348}
349
350func (s *simpleItem) ID() string {
351 return s.id
352}
353
354func (s *simpleItem) Init() tea.Cmd {
355 return nil
356}
357
358func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
359 return s, nil
360}
361
362func (s *simpleItem) View() string {
363 return lipgloss.NewStyle().Width(s.width).Render(s.content)
364}
365
366func (l *simpleItem) GetSize() (int, int) {
367 return l.width, 0
368}
369
370// SetSize implements Item.
371func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
372 s.width = width
373 return nil
374}
375
376func (s *selectableItem) View() string {
377 if s.focused {
378 return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
379 }
380 return lipgloss.NewStyle().Width(s.width).Render(s.content)
381}
382
383// Blur implements SimpleItem.
384func (s *selectableItem) Blur() tea.Cmd {
385 s.focused = false
386 return nil
387}
388
389// Focus implements SimpleItem.
390func (s *selectableItem) Focus() tea.Cmd {
391 s.focused = true
392 return nil
393}
394
395// IsFocused implements SimpleItem.
396func (s *selectableItem) IsFocused() bool {
397 return s.focused
398}
399
400func execCmd(m tea.Model, cmd tea.Cmd) {
401 for cmd != nil {
402 msg := cmd()
403 m, cmd = m.Update(msg)
404 }
405}