@@ -0,0 +1,602 @@
1+package list
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/glamour/v2/ansi"
8+ uv "github.com/charmbracelet/ultraviolet"
9+)
10+
11+func TestRenderHelper(t *testing.T) {
12+ items := []Item{
13+ NewStringItem("1", "Item 1"),
14+ NewStringItem("2", "Item 2"),
15+ NewStringItem("3", "Item 3"),
16+ }
17+
18+ l := New(items...)
19+ l.SetSize(80, 10)
20+
21+ // Render to string
22+ output := l.Render()
23+
24+ if len(output) == 0 {
25+ t.Error("expected non-empty output from Render()")
26+ }
27+
28+ // Check that output contains the items
29+ if !strings.Contains(output, "Item 1") {
30+ t.Error("expected output to contain 'Item 1'")
31+ }
32+ if !strings.Contains(output, "Item 2") {
33+ t.Error("expected output to contain 'Item 2'")
34+ }
35+ if !strings.Contains(output, "Item 3") {
36+ t.Error("expected output to contain 'Item 3'")
37+ }
38+}
39+
40+func TestRenderWithScrolling(t *testing.T) {
41+ items := []Item{
42+ NewStringItem("1", "Item 1"),
43+ NewStringItem("2", "Item 2"),
44+ NewStringItem("3", "Item 3"),
45+ NewStringItem("4", "Item 4"),
46+ NewStringItem("5", "Item 5"),
47+ }
48+
49+ l := New(items...)
50+ l.SetSize(80, 2) // Small viewport
51+
52+ // Initial render should show first 2 items
53+ output := l.Render()
54+ if !strings.Contains(output, "Item 1") {
55+ t.Error("expected output to contain 'Item 1'")
56+ }
57+ if !strings.Contains(output, "Item 2") {
58+ t.Error("expected output to contain 'Item 2'")
59+ }
60+ if strings.Contains(output, "Item 3") {
61+ t.Error("expected output to NOT contain 'Item 3' in initial view")
62+ }
63+
64+ // Scroll down and render
65+ l.ScrollBy(2)
66+ output = l.Render()
67+
68+ // Now should show items 3 and 4
69+ if strings.Contains(output, "Item 1") {
70+ t.Error("expected output to NOT contain 'Item 1' after scrolling")
71+ }
72+ if !strings.Contains(output, "Item 3") {
73+ t.Error("expected output to contain 'Item 3' after scrolling")
74+ }
75+ if !strings.Contains(output, "Item 4") {
76+ t.Error("expected output to contain 'Item 4' after scrolling")
77+ }
78+}
79+
80+func TestRenderEmptyList(t *testing.T) {
81+ l := New()
82+ l.SetSize(80, 10)
83+
84+ output := l.Render()
85+ if output != "" {
86+ t.Errorf("expected empty output for empty list, got: %q", output)
87+ }
88+}
89+
90+func TestRenderVsDrawConsistency(t *testing.T) {
91+ items := []Item{
92+ NewStringItem("1", "Item 1"),
93+ NewStringItem("2", "Item 2"),
94+ }
95+
96+ l := New(items...)
97+ l.SetSize(80, 10)
98+
99+ // Render using Render() method
100+ renderOutput := l.Render()
101+
102+ // Render using Draw() method
103+ screen := uv.NewScreenBuffer(80, 10)
104+ area := uv.Rect(0, 0, 80, 10)
105+ l.Draw(&screen, area)
106+ drawOutput := screen.Render()
107+
108+ // Trim any trailing whitespace for comparison
109+ renderOutput = strings.TrimRight(renderOutput, "\n")
110+ drawOutput = strings.TrimRight(drawOutput, "\n")
111+
112+ // Both methods should produce the same output
113+ if renderOutput != drawOutput {
114+ t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q",
115+ renderOutput, drawOutput)
116+ }
117+}
118+
119+func BenchmarkRender(b *testing.B) {
120+ items := make([]Item, 100)
121+ for i := range items {
122+ items[i] = NewStringItem(string(rune(i)), "Item content here")
123+ }
124+
125+ l := New(items...)
126+ l.SetSize(80, 24)
127+ l.Render() // Prime the buffer
128+
129+ b.ResetTimer()
130+ for i := 0; i < b.N; i++ {
131+ _ = l.Render()
132+ }
133+}
134+
135+func BenchmarkRenderWithScrolling(b *testing.B) {
136+ items := make([]Item, 1000)
137+ for i := range items {
138+ items[i] = NewStringItem(string(rune(i)), "Item content here")
139+ }
140+
141+ l := New(items...)
142+ l.SetSize(80, 24)
143+ l.Render() // Prime the buffer
144+
145+ b.ResetTimer()
146+ for i := 0; i < b.N; i++ {
147+ l.ScrollBy(1)
148+ _ = l.Render()
149+ }
150+}
151+
152+func TestStringItemCache(t *testing.T) {
153+ item := NewStringItem("1", "Test content")
154+
155+ // First draw at width 80 should populate cache
156+ screen1 := uv.NewScreenBuffer(80, 5)
157+ area1 := uv.Rect(0, 0, 80, 5)
158+ item.Draw(&screen1, area1)
159+
160+ if len(item.cache) != 1 {
161+ t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache))
162+ }
163+ if _, ok := item.cache[80]; !ok {
164+ t.Error("expected cache to have entry for width 80")
165+ }
166+
167+ // Second draw at same width should reuse cache
168+ screen2 := uv.NewScreenBuffer(80, 5)
169+ area2 := uv.Rect(0, 0, 80, 5)
170+ item.Draw(&screen2, area2)
171+
172+ if len(item.cache) != 1 {
173+ t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache))
174+ }
175+
176+ // Draw at different width should add to cache
177+ screen3 := uv.NewScreenBuffer(40, 5)
178+ area3 := uv.Rect(0, 0, 40, 5)
179+ item.Draw(&screen3, area3)
180+
181+ if len(item.cache) != 2 {
182+ t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache))
183+ }
184+ if _, ok := item.cache[40]; !ok {
185+ t.Error("expected cache to have entry for width 40")
186+ }
187+}
188+
189+func TestWrappingItemHeight(t *testing.T) {
190+ // Short text that fits in one line
191+ item1 := NewWrappingStringItem("1", "Short")
192+ if h := item1.Height(80); h != 1 {
193+ t.Errorf("expected height 1 for short text, got %d", h)
194+ }
195+
196+ // Long text that wraps
197+ longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
198+ item2 := NewWrappingStringItem("2", longText)
199+
200+ // At width 80, should be fewer lines than width 20
201+ height80 := item2.Height(80)
202+ height20 := item2.Height(20)
203+
204+ if height20 <= height80 {
205+ t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)",
206+ height20, height80)
207+ }
208+
209+ // Non-wrapping version should always be 1 line
210+ item3 := NewStringItem("3", longText)
211+ if h := item3.Height(20); h != 1 {
212+ t.Errorf("expected height 1 for non-wrapping item, got %d", h)
213+ }
214+}
215+
216+func TestMarkdownItemBasic(t *testing.T) {
217+ markdown := "# Hello\n\nThis is a **test**."
218+ item := NewMarkdownItem("1", markdown)
219+
220+ if item.ID() != "1" {
221+ t.Errorf("expected ID '1', got '%s'", item.ID())
222+ }
223+
224+ // Test that height is calculated
225+ height := item.Height(80)
226+ if height < 1 {
227+ t.Errorf("expected height >= 1, got %d", height)
228+ }
229+
230+ // Test drawing
231+ screen := uv.NewScreenBuffer(80, 10)
232+ area := uv.Rect(0, 0, 80, 10)
233+ item.Draw(&screen, area)
234+
235+ // Should not panic and should render something
236+ rendered := screen.Render()
237+ if len(rendered) == 0 {
238+ t.Error("expected non-empty rendered output")
239+ }
240+}
241+
242+func TestMarkdownItemCache(t *testing.T) {
243+ markdown := "# Test\n\nSome content."
244+ item := NewMarkdownItem("1", markdown)
245+
246+ // First render at width 80 should populate cache
247+ height1 := item.Height(80)
248+ if len(item.cache) != 1 {
249+ t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
250+ }
251+
252+ // Second render at same width should reuse cache
253+ height2 := item.Height(80)
254+ if height1 != height2 {
255+ t.Errorf("expected consistent height, got %d then %d", height1, height2)
256+ }
257+ if len(item.cache) != 1 {
258+ t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
259+ }
260+
261+ // Render at different width should add to cache
262+ _ = item.Height(40)
263+ if len(item.cache) != 2 {
264+ t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
265+ }
266+}
267+
268+func TestMarkdownItemMaxCacheWidth(t *testing.T) {
269+ markdown := "# Test\n\nSome content."
270+ item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
271+
272+ // Render at width 40 (below limit) - should cache at width 40
273+ _ = item.Height(40)
274+ if len(item.cache) != 1 {
275+ t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
276+ }
277+
278+ // Render at width 80 (above limit) - should cap to 50 and cache
279+ _ = item.Height(80)
280+ // Cache should have width 50 entry (capped from 80)
281+ if len(item.cache) != 2 {
282+ t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
283+ }
284+ if _, ok := item.cache[50]; !ok {
285+ t.Error("expected cache to have entry for width 50 (capped from 80)")
286+ }
287+
288+ // Render at width 100 (also above limit) - should reuse cached width 50
289+ _ = item.Height(100)
290+ if len(item.cache) != 2 {
291+ t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
292+ }
293+}
294+
295+func TestMarkdownItemWithStyleConfig(t *testing.T) {
296+ markdown := "# Styled\n\nContent with **bold** text."
297+
298+ // Create a custom style config
299+ styleConfig := ansi.StyleConfig{
300+ Document: ansi.StyleBlock{
301+ Margin: uintPtr(0),
302+ },
303+ }
304+
305+ item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig)
306+
307+ // Render should use the custom style
308+ height := item.Height(80)
309+ if height < 1 {
310+ t.Errorf("expected height >= 1, got %d", height)
311+ }
312+
313+ // Draw should work without panic
314+ screen := uv.NewScreenBuffer(80, 10)
315+ area := uv.Rect(0, 0, 80, 10)
316+ item.Draw(&screen, area)
317+
318+ rendered := screen.Render()
319+ if len(rendered) == 0 {
320+ t.Error("expected non-empty rendered output with custom style")
321+ }
322+}
323+
324+func TestMarkdownItemInList(t *testing.T) {
325+ items := []Item{
326+ NewMarkdownItem("1", "# First\n\nMarkdown item."),
327+ NewMarkdownItem("2", "# Second\n\nAnother item."),
328+ NewStringItem("3", "Regular string item"),
329+ }
330+
331+ l := New(items...)
332+ l.SetSize(80, 20)
333+
334+ // Should render without error
335+ output := l.Render()
336+ if len(output) == 0 {
337+ t.Error("expected non-empty output from list with markdown items")
338+ }
339+
340+ // Should contain content from markdown items
341+ if !strings.Contains(output, "First") {
342+ t.Error("expected output to contain 'First'")
343+ }
344+ if !strings.Contains(output, "Second") {
345+ t.Error("expected output to contain 'Second'")
346+ }
347+ if !strings.Contains(output, "Regular string item") {
348+ t.Error("expected output to contain 'Regular string item'")
349+ }
350+}
351+
352+func TestMarkdownItemHeightWithWidth(t *testing.T) {
353+ // Test that widths are capped to maxWidth
354+ markdown := "This is a paragraph with some text."
355+
356+ item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
357+
358+ // At width 30 (below limit), should cache and render at width 30
359+ height30 := item.Height(30)
360+ if height30 < 1 {
361+ t.Errorf("expected height >= 1, got %d", height30)
362+ }
363+
364+ // At width 100 (above maxWidth), should cap to 50 and cache
365+ height100 := item.Height(100)
366+ if height100 < 1 {
367+ t.Errorf("expected height >= 1, got %d", height100)
368+ }
369+
370+ // Both should be cached (width 30 and capped width 50)
371+ if len(item.cache) != 2 {
372+ t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
373+ }
374+ if _, ok := item.cache[30]; !ok {
375+ t.Error("expected cache to have entry for width 30")
376+ }
377+ if _, ok := item.cache[50]; !ok {
378+ t.Error("expected cache to have entry for width 50 (capped from 100)")
379+ }
380+}
381+
382+func BenchmarkMarkdownItemRender(b *testing.B) {
383+ markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
384+ item := NewMarkdownItem("1", markdown)
385+
386+ // Prime the cache
387+ screen := uv.NewScreenBuffer(80, 10)
388+ area := uv.Rect(0, 0, 80, 10)
389+ item.Draw(&screen, area)
390+
391+ b.ResetTimer()
392+ for i := 0; i < b.N; i++ {
393+ screen := uv.NewScreenBuffer(80, 10)
394+ area := uv.Rect(0, 0, 80, 10)
395+ item.Draw(&screen, area)
396+ }
397+}
398+
399+func BenchmarkMarkdownItemUncached(b *testing.B) {
400+ markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
401+
402+ b.ResetTimer()
403+ for i := 0; i < b.N; i++ {
404+ item := NewMarkdownItem("1", markdown)
405+ screen := uv.NewScreenBuffer(80, 10)
406+ area := uv.Rect(0, 0, 80, 10)
407+ item.Draw(&screen, area)
408+ }
409+}
410+
411+func TestSpacerItem(t *testing.T) {
412+ spacer := NewSpacerItem("spacer1", 3)
413+
414+ // Check ID
415+ if spacer.ID() != "spacer1" {
416+ t.Errorf("expected ID 'spacer1', got %q", spacer.ID())
417+ }
418+
419+ // Check height
420+ if h := spacer.Height(80); h != 3 {
421+ t.Errorf("expected height 3, got %d", h)
422+ }
423+
424+ // Height should be constant regardless of width
425+ if h := spacer.Height(20); h != 3 {
426+ t.Errorf("expected height 3 for width 20, got %d", h)
427+ }
428+
429+ // Draw should not produce any visible content
430+ screen := uv.NewScreenBuffer(20, 3)
431+ area := uv.Rect(0, 0, 20, 3)
432+ spacer.Draw(&screen, area)
433+
434+ output := screen.Render()
435+ // Should be empty (just spaces)
436+ for _, line := range strings.Split(output, "\n") {
437+ trimmed := strings.TrimSpace(line)
438+ if trimmed != "" {
439+ t.Errorf("expected empty spacer output, got: %q", line)
440+ }
441+ }
442+}
443+
444+func TestSpacerItemInList(t *testing.T) {
445+ // Create a list with items separated by spacers
446+ items := []Item{
447+ NewStringItem("1", "Item 1"),
448+ NewSpacerItem("spacer1", 1),
449+ NewStringItem("2", "Item 2"),
450+ NewSpacerItem("spacer2", 2),
451+ NewStringItem("3", "Item 3"),
452+ }
453+
454+ l := New(items...)
455+ l.SetSize(20, 10)
456+
457+ output := l.Render()
458+
459+ // Should contain all three items
460+ if !strings.Contains(output, "Item 1") {
461+ t.Error("expected output to contain 'Item 1'")
462+ }
463+ if !strings.Contains(output, "Item 2") {
464+ t.Error("expected output to contain 'Item 2'")
465+ }
466+ if !strings.Contains(output, "Item 3") {
467+ t.Error("expected output to contain 'Item 3'")
468+ }
469+
470+ // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
471+ expectedHeight := 6
472+ if l.TotalHeight() != expectedHeight {
473+ t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
474+ }
475+}
476+
477+func TestSpacerItemNavigation(t *testing.T) {
478+ // Spacers should not be selectable (they're not focusable)
479+ items := []Item{
480+ NewStringItem("1", "Item 1"),
481+ NewSpacerItem("spacer1", 1),
482+ NewStringItem("2", "Item 2"),
483+ }
484+
485+ l := New(items...)
486+ l.SetSize(20, 10)
487+
488+ // Select first item
489+ l.SetSelectedIndex(0)
490+ if l.SelectedIndex() != 0 {
491+ t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
492+ }
493+
494+ // Can select the spacer (it's a valid item, just not focusable)
495+ l.SetSelectedIndex(1)
496+ if l.SelectedIndex() != 1 {
497+ t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
498+ }
499+
500+ // Can select item after spacer
501+ l.SetSelectedIndex(2)
502+ if l.SelectedIndex() != 2 {
503+ t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
504+ }
505+}
506+
507+// Helper function to create a pointer to uint
508+func uintPtr(v uint) *uint {
509+ return &v
510+}
511+
512+func TestListDoesNotEatLastLine(t *testing.T) {
513+ // Create items that exactly fill the viewport
514+ items := []Item{
515+ NewStringItem("1", "Line 1"),
516+ NewStringItem("2", "Line 2"),
517+ NewStringItem("3", "Line 3"),
518+ NewStringItem("4", "Line 4"),
519+ NewStringItem("5", "Line 5"),
520+ }
521+
522+ // Create list with height exactly matching content (5 lines, no gaps)
523+ l := New(items...)
524+ l.SetSize(20, 5)
525+
526+ // Render the list
527+ output := l.Render()
528+
529+ // Count actual lines in output
530+ lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n")
531+ actualLineCount := 0
532+ for _, line := range lines {
533+ if strings.TrimSpace(line) != "" {
534+ actualLineCount++
535+ }
536+ }
537+
538+ // All 5 items should be visible
539+ if !strings.Contains(output, "Line 1") {
540+ t.Error("expected output to contain 'Line 1'")
541+ }
542+ if !strings.Contains(output, "Line 2") {
543+ t.Error("expected output to contain 'Line 2'")
544+ }
545+ if !strings.Contains(output, "Line 3") {
546+ t.Error("expected output to contain 'Line 3'")
547+ }
548+ if !strings.Contains(output, "Line 4") {
549+ t.Error("expected output to contain 'Line 4'")
550+ }
551+ if !strings.Contains(output, "Line 5") {
552+ t.Error("expected output to contain 'Line 5'")
553+ }
554+
555+ if actualLineCount != 5 {
556+ t.Errorf("expected 5 lines with content, got %d", actualLineCount)
557+ }
558+}
559+
560+func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
561+ // Create more items than viewport height
562+ items := []Item{
563+ NewStringItem("1", "Item 1"),
564+ NewStringItem("2", "Item 2"),
565+ NewStringItem("3", "Item 3"),
566+ NewStringItem("4", "Item 4"),
567+ NewStringItem("5", "Item 5"),
568+ NewStringItem("6", "Item 6"),
569+ NewStringItem("7", "Item 7"),
570+ }
571+
572+ // Viewport shows 3 items at a time
573+ l := New(items...)
574+ l.SetSize(20, 3)
575+
576+ // Need to render first to build the buffer and calculate total height
577+ _ = l.Render()
578+
579+ // Now scroll to bottom
580+ l.ScrollToBottom()
581+
582+ output := l.Render()
583+
584+ t.Logf("Output:\n%s", output)
585+ t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
586+
587+ // Should show last 3 items: 5, 6, 7
588+ if !strings.Contains(output, "Item 5") {
589+ t.Error("expected output to contain 'Item 5'")
590+ }
591+ if !strings.Contains(output, "Item 6") {
592+ t.Error("expected output to contain 'Item 6'")
593+ }
594+ if !strings.Contains(output, "Item 7") {
595+ t.Error("expected output to contain 'Item 7'")
596+ }
597+
598+ // Should not show earlier items
599+ if strings.Contains(output, "Item 1") {
600+ t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
601+ }
602+}