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("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}