list_test.go

  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}