1package list
2
3import (
4 "strings"
5 "testing"
6
7 "charm.land/lipgloss/v2"
8 uv "github.com/charmbracelet/ultraviolet"
9 "github.com/stretchr/testify/require"
10)
11
12func TestNewList(t *testing.T) {
13 items := []Item{
14 NewStringItem("Item 1"),
15 NewStringItem("Item 2"),
16 NewStringItem("Item 3"),
17 }
18
19 l := New(items...)
20 l.SetSize(80, 24)
21
22 if len(l.items) != 3 {
23 t.Errorf("expected 3 items, got %d", len(l.items))
24 }
25
26 if l.width != 80 || l.height != 24 {
27 t.Errorf("expected size 80x24, got %dx%d", l.width, l.height)
28 }
29}
30
31func TestListDraw(t *testing.T) {
32 items := []Item{
33 NewStringItem("Item 1"),
34 NewStringItem("Item 2"),
35 NewStringItem("Item 3"),
36 }
37
38 l := New(items...)
39 l.SetSize(80, 10)
40
41 // Create a screen buffer to draw into
42 screen := uv.NewScreenBuffer(80, 10)
43 area := uv.Rect(0, 0, 80, 10)
44
45 // Draw the list
46 l.Draw(&screen, area)
47
48 // Verify the buffer has content
49 output := screen.Render()
50 if len(output) == 0 {
51 t.Error("expected non-empty output")
52 }
53}
54
55func TestListAppendItem(t *testing.T) {
56 items := []Item{
57 NewStringItem("Item 1"),
58 }
59
60 l := New(items...)
61 l.AppendItem(NewStringItem("Item 2"))
62
63 if len(l.items) != 2 {
64 t.Errorf("expected 2 items after append, got %d", len(l.items))
65 }
66}
67
68func TestListDeleteItem(t *testing.T) {
69 items := []Item{
70 NewStringItem("Item 1"),
71 NewStringItem("Item 2"),
72 NewStringItem("Item 3"),
73 }
74
75 l := New(items...)
76 l.DeleteItem(2)
77
78 if len(l.items) != 2 {
79 t.Errorf("expected 2 items after delete, got %d", len(l.items))
80 }
81}
82
83func TestListUpdateItem(t *testing.T) {
84 items := []Item{
85 NewStringItem("Item 1"),
86 NewStringItem("Item 2"),
87 }
88
89 l := New(items...)
90 l.SetSize(80, 10)
91
92 // Update item
93 newItem := NewStringItem("Updated Item 2")
94 l.UpdateItem(1, newItem)
95
96 if l.items[1].(*StringItem).content != "Updated Item 2" {
97 t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
98 }
99}
100
101func TestListSelection(t *testing.T) {
102 items := []Item{
103 NewStringItem("Item 1"),
104 NewStringItem("Item 2"),
105 NewStringItem("Item 3"),
106 }
107
108 l := New(items...)
109 l.SetSelected(0)
110
111 if l.SelectedIndex() != 0 {
112 t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
113 }
114
115 l.SelectNext()
116 if l.SelectedIndex() != 1 {
117 t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex())
118 }
119
120 l.SelectPrev()
121 if l.SelectedIndex() != 0 {
122 t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex())
123 }
124}
125
126func TestListScrolling(t *testing.T) {
127 items := []Item{
128 NewStringItem("Item 1"),
129 NewStringItem("Item 2"),
130 NewStringItem("Item 3"),
131 NewStringItem("Item 4"),
132 NewStringItem("Item 5"),
133 }
134
135 l := New(items...)
136 l.SetSize(80, 2) // Small viewport
137
138 // Draw to initialize the master buffer
139 screen := uv.NewScreenBuffer(80, 2)
140 area := uv.Rect(0, 0, 80, 2)
141 l.Draw(&screen, area)
142
143 if l.Offset() != 0 {
144 t.Errorf("expected initial offset 0, got %d", l.Offset())
145 }
146
147 l.ScrollBy(2)
148 if l.Offset() != 2 {
149 t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset())
150 }
151
152 l.ScrollToTop()
153 if l.Offset() != 0 {
154 t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset())
155 }
156}
157
158// FocusableTestItem is a test item that implements Focusable.
159type FocusableTestItem struct {
160 id string
161 content string
162 focused bool
163}
164
165func (f *FocusableTestItem) ID() string {
166 return f.id
167}
168
169func (f *FocusableTestItem) Height(width int) int {
170 return 1
171}
172
173func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) {
174 prefix := "[ ]"
175 if f.focused {
176 prefix = "[X]"
177 }
178 content := prefix + " " + f.content
179 styled := uv.NewStyledString(content)
180 styled.Draw(scr, area)
181}
182
183func (f *FocusableTestItem) Focus() {
184 f.focused = true
185}
186
187func (f *FocusableTestItem) Blur() {
188 f.focused = false
189}
190
191func (f *FocusableTestItem) IsFocused() bool {
192 return f.focused
193}
194
195func TestListFocus(t *testing.T) {
196 items := []Item{
197 &FocusableTestItem{id: "1", content: "Item 1"},
198 &FocusableTestItem{id: "2", content: "Item 2"},
199 }
200
201 l := New(items...)
202 l.SetSize(80, 10)
203 l.SetSelected(0)
204
205 // Focus the list
206 l.Focus()
207
208 if !l.Focused() {
209 t.Error("expected list to be focused")
210 }
211
212 // Check if selected item is focused
213 selectedItem := l.SelectedItem().(*FocusableTestItem)
214 if !selectedItem.IsFocused() {
215 t.Error("expected selected item to be focused")
216 }
217
218 // Select next and check focus changes
219 l.SelectNext()
220 if selectedItem.IsFocused() {
221 t.Error("expected previous item to be blurred")
222 }
223
224 newSelectedItem := l.SelectedItem().(*FocusableTestItem)
225 if !newSelectedItem.IsFocused() {
226 t.Error("expected new selected item to be focused")
227 }
228
229 // Blur the list
230 l.Blur()
231 if l.Focused() {
232 t.Error("expected list to be blurred")
233 }
234}
235
236// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug:
237// Append items until viewport is full, select last, then navigate backwards.
238func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
239 t.Parallel()
240
241 focusStyle := lipgloss.NewStyle().
242 Border(lipgloss.RoundedBorder()).
243 BorderForeground(lipgloss.Color("86"))
244
245 blurStyle := lipgloss.NewStyle().
246 Border(lipgloss.RoundedBorder()).
247 BorderForeground(lipgloss.Color("240"))
248
249 // Start with one item
250 items := []Item{
251 NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
252 }
253
254 l := New(items...)
255 l.SetSize(20, 15) // 15 lines viewport height
256 l.SetSelected(0)
257 l.Focus()
258
259 // Initial draw to build buffer
260 screen := uv.NewScreenBuffer(20, 15)
261 l.Draw(&screen, uv.Rect(0, 0, 20, 15))
262
263 // Append items until we exceed viewport height
264 // Each focusable item with border is 5 lines tall
265 for i := 2; i <= 4; i++ {
266 item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
267 l.AppendItem(item)
268 }
269
270 // Select the last item
271 l.SetSelected(3)
272
273 // Draw
274 screen = uv.NewScreenBuffer(20, 15)
275 l.Draw(&screen, uv.Rect(0, 0, 20, 15))
276 output := screen.Render()
277
278 t.Logf("After selecting last item:\n%s", output)
279 require.Contains(t, output, "38;5;86", "expected focus color on last item")
280
281 // Now navigate backwards
282 l.SelectPrev()
283
284 screen = uv.NewScreenBuffer(20, 15)
285 l.Draw(&screen, uv.Rect(0, 0, 20, 15))
286 output = screen.Render()
287
288 t.Logf("After SelectPrev:\n%s", output)
289 require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev")
290
291 // Navigate backwards again
292 l.SelectPrev()
293
294 screen = uv.NewScreenBuffer(20, 15)
295 l.Draw(&screen, uv.Rect(0, 0, 20, 15))
296 output = screen.Render()
297
298 t.Logf("After second SelectPrev:\n%s", output)
299 require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev")
300}
301
302func TestFocusableItemUpdate(t *testing.T) {
303 // Create styles with borders
304 focusStyle := lipgloss.NewStyle().
305 Border(lipgloss.RoundedBorder()).
306 BorderForeground(lipgloss.Color("86"))
307
308 blurStyle := lipgloss.NewStyle().
309 Border(lipgloss.RoundedBorder()).
310 BorderForeground(lipgloss.Color("240"))
311
312 // Create a focusable item
313 item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle)
314
315 // Initially not focused - render with blur style
316 screen1 := uv.NewScreenBuffer(20, 5)
317 area := uv.Rect(0, 0, 20, 5)
318 item.Draw(&screen1, area)
319 output1 := screen1.Render()
320
321 // Focus the item
322 item.Focus()
323
324 // Render again - should show focus style
325 screen2 := uv.NewScreenBuffer(20, 5)
326 item.Draw(&screen2, area)
327 output2 := screen2.Render()
328
329 // Outputs should be different (different border colors)
330 if output1 == output2 {
331 t.Error("expected different output after focusing, but got same output")
332 }
333
334 // Verify focus state
335 if !item.IsFocused() {
336 t.Error("expected item to be focused")
337 }
338
339 // Blur the item
340 item.Blur()
341
342 // Render again - should show blur style again
343 screen3 := uv.NewScreenBuffer(20, 5)
344 item.Draw(&screen3, area)
345 output3 := screen3.Render()
346
347 // Output should match original blur output
348 if output1 != output3 {
349 t.Error("expected same output after blurring as initial state")
350 }
351
352 // Verify blur state
353 if item.IsFocused() {
354 t.Error("expected item to be blurred")
355 }
356}
357
358func TestFocusableItemHeightWithBorder(t *testing.T) {
359 // Create a style with a border (adds 2 to vertical height)
360 borderStyle := lipgloss.NewStyle().
361 Border(lipgloss.RoundedBorder())
362
363 // Item without styles has height 1
364 plainItem := NewStringItem("Test")
365 plainHeight := plainItem.Height(20)
366 if plainHeight != 1 {
367 t.Errorf("expected plain height 1, got %d", plainHeight)
368 }
369
370 // Item with border should add border height (2 lines)
371 item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle)
372 itemHeight := item.Height(20)
373 expectedHeight := 1 + 2 // content + border
374 if itemHeight != expectedHeight {
375 t.Errorf("expected height %d (content 1 + border 2), got %d",
376 expectedHeight, itemHeight)
377 }
378}
379
380func TestFocusableItemInList(t *testing.T) {
381 focusStyle := lipgloss.NewStyle().
382 Border(lipgloss.RoundedBorder()).
383 BorderForeground(lipgloss.Color("86"))
384
385 blurStyle := lipgloss.NewStyle().
386 Border(lipgloss.RoundedBorder()).
387 BorderForeground(lipgloss.Color("240"))
388
389 // Create list with focusable items
390 items := []Item{
391 NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
392 NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
393 NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
394 }
395
396 l := New(items...)
397 l.SetSize(80, 20)
398 l.SetSelected(0)
399
400 // Focus the list
401 l.Focus()
402
403 // First item should be focused
404 firstItem := items[0].(*StringItem)
405 if !firstItem.IsFocused() {
406 t.Error("expected first item to be focused after focusing list")
407 }
408
409 // Render to ensure changes are visible
410 output1 := l.Render()
411 if !strings.Contains(output1, "Item 1") {
412 t.Error("expected output to contain first item")
413 }
414
415 // Select second item
416 l.SetSelected(1)
417
418 // First item should be blurred, second focused
419 if firstItem.IsFocused() {
420 t.Error("expected first item to be blurred after changing selection")
421 }
422
423 secondItem := items[1].(*StringItem)
424 if !secondItem.IsFocused() {
425 t.Error("expected second item to be focused after selection")
426 }
427
428 // Render again - should show updated focus
429 output2 := l.Render()
430 if !strings.Contains(output2, "Item 2") {
431 t.Error("expected output to contain second item")
432 }
433
434 // Outputs should be different
435 if output1 == output2 {
436 t.Error("expected different output after selection change")
437 }
438}
439
440func TestFocusableItemWithNilStyles(t *testing.T) {
441 // Test with nil styles - should render inner item directly
442 item := NewStringItem("Plain Item").WithFocusStyles(nil, nil)
443
444 // Height should be based on content (no border since styles are nil)
445 itemHeight := item.Height(20)
446 if itemHeight != 1 {
447 t.Errorf("expected height 1 (no border), got %d", itemHeight)
448 }
449
450 // Draw should work without styles
451 screen := uv.NewScreenBuffer(20, 5)
452 area := uv.Rect(0, 0, 20, 5)
453 item.Draw(&screen, area)
454 output := screen.Render()
455
456 // Should contain the inner content
457 if !strings.Contains(output, "Plain Item") {
458 t.Error("expected output to contain inner item content")
459 }
460
461 // Focus/blur should still work but not change appearance
462 item.Focus()
463 screen2 := uv.NewScreenBuffer(20, 5)
464 item.Draw(&screen2, area)
465 output2 := screen2.Render()
466
467 // Output should be identical since no styles
468 if output != output2 {
469 t.Error("expected same output with nil styles whether focused or not")
470 }
471
472 if !item.IsFocused() {
473 t.Error("expected item to be focused")
474 }
475}
476
477func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
478 // Test with only focus style (blur is nil)
479 focusStyle := lipgloss.NewStyle().
480 Border(lipgloss.RoundedBorder()).
481 BorderForeground(lipgloss.Color("86"))
482
483 item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil)
484
485 // When not focused, should use nil blur style (no border)
486 screen1 := uv.NewScreenBuffer(20, 5)
487 area := uv.Rect(0, 0, 20, 5)
488 item.Draw(&screen1, area)
489 output1 := screen1.Render()
490
491 // Focus the item
492 item.Focus()
493 screen2 := uv.NewScreenBuffer(20, 5)
494 item.Draw(&screen2, area)
495 output2 := screen2.Render()
496
497 // Outputs should be different (focused has border, blurred doesn't)
498 if output1 == output2 {
499 t.Error("expected different output when only focus style is set")
500 }
501}
502
503func TestFocusableItemLastLineNotEaten(t *testing.T) {
504 // Create focusable items with borders
505 focusStyle := lipgloss.NewStyle().
506 Padding(1).
507 Border(lipgloss.RoundedBorder()).
508 BorderForeground(lipgloss.Color("86"))
509
510 blurStyle := lipgloss.NewStyle().
511 BorderForeground(lipgloss.Color("240"))
512
513 items := []Item{
514 NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
515 Gap,
516 NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
517 Gap,
518 NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
519 Gap,
520 NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle),
521 Gap,
522 NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle),
523 }
524
525 // Items with padding(1) and border are 5 lines each
526 // Viewport of 10 lines fits exactly 2 items
527 l := New()
528 l.SetSize(20, 10)
529
530 for _, item := range items {
531 l.AppendItem(item)
532 }
533
534 // Focus the list
535 l.Focus()
536
537 // Select last item
538 l.SetSelected(len(items) - 1)
539
540 // Scroll to bottom
541 l.ScrollToBottom()
542
543 output := l.Render()
544
545 t.Logf("Output:\n%s", output)
546 t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
547
548 // Select previous - will skip gaps and go to Item 4
549 l.SelectPrev()
550
551 output = l.Render()
552
553 t.Logf("Output:\n%s", output)
554 t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
555
556 // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused)
557 if !strings.Contains(output, "Item 3") {
558 t.Error("expected output to contain 'Item 3'")
559 }
560 if !strings.Contains(output, "Item 4") {
561 t.Error("expected output to contain 'Item 4'")
562 }
563 if !strings.Contains(output, "Item 5") {
564 t.Error("expected output to contain 'Item 5'")
565 }
566
567 // Count bottom borders - should have 1 (focused item 4)
568 bottomBorderCount := 0
569 for _, line := range strings.Split(output, "\r\n") {
570 if strings.Contains(line, "╰") || strings.Contains(line, "└") {
571 bottomBorderCount++
572 }
573 }
574
575 if bottomBorderCount != 1 {
576 t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount)
577 }
578}