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