1package list
   2
   3import (
   4	"fmt"
   5	"strings"
   6	"testing"
   7
   8	tea "github.com/charmbracelet/bubbletea/v2"
   9	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
  10	"github.com/charmbracelet/lipgloss/v2"
  11	"github.com/charmbracelet/x/exp/golden"
  12	"github.com/google/uuid"
  13	"github.com/stretchr/testify/assert"
  14	"github.com/stretchr/testify/require"
  15)
  16
  17func TestViewPosition(t *testing.T) {
  18	t.Parallel()
  19	
  20	t.Run("forward direction - normal scrolling", func(t *testing.T) {
  21		t.Parallel()
  22		items := []Item{createItem("test", 1)}
  23		l := New(items, WithDirectionForward(), WithSize(20, 10)).(*list[Item])
  24		l.virtualHeight = 50
  25		
  26		// At the top
  27		l.offset = 0
  28		start, end := l.viewPosition()
  29		assert.Equal(t, 0, start)
  30		assert.Equal(t, 9, end)
  31		
  32		// In the middle
  33		l.offset = 20
  34		start, end = l.viewPosition()
  35		assert.Equal(t, 20, start)
  36		assert.Equal(t, 29, end)
  37		
  38		// Near the bottom
  39		l.offset = 40
  40		start, end = l.viewPosition()
  41		assert.Equal(t, 40, start)
  42		assert.Equal(t, 49, end)
  43		
  44		// Past the maximum valid offset (should be clamped)
  45		l.offset = 45
  46		start, end = l.viewPosition()
  47		assert.Equal(t, 40, start) // Clamped to max valid offset
  48		assert.Equal(t, 49, end)
  49		
  50		// Way past the end (should be clamped)
  51		l.offset = 100
  52		start, end = l.viewPosition()
  53		assert.Equal(t, 40, start) // Clamped to max valid offset
  54		assert.Equal(t, 49, end)
  55	})
  56	
  57	t.Run("forward direction - edge case with exact fit", func(t *testing.T) {
  58		t.Parallel()
  59		items := []Item{createItem("test", 1)}
  60		l := New(items, WithDirectionForward(), WithSize(20, 10)).(*list[Item])
  61		l.virtualHeight = 10
  62		
  63		l.offset = 0
  64		start, end := l.viewPosition()
  65		assert.Equal(t, 0, start)
  66		assert.Equal(t, 9, end)
  67		
  68		// Offset beyond valid range should be clamped
  69		l.offset = 5
  70		start, end = l.viewPosition()
  71		assert.Equal(t, 0, start)
  72		assert.Equal(t, 9, end)
  73	})
  74	
  75	t.Run("forward direction - content smaller than viewport", func(t *testing.T) {
  76		t.Parallel()
  77		items := []Item{createItem("test", 1)}
  78		l := New(items, WithDirectionForward(), WithSize(20, 10)).(*list[Item])
  79		l.virtualHeight = 5
  80		
  81		l.offset = 0
  82		start, end := l.viewPosition()
  83		assert.Equal(t, 0, start)
  84		assert.Equal(t, 4, end)
  85		
  86		// Any offset should be clamped to 0
  87		l.offset = 10
  88		start, end = l.viewPosition()
  89		assert.Equal(t, 0, start)
  90		assert.Equal(t, 4, end)
  91	})
  92	
  93	t.Run("backward direction - normal scrolling", func(t *testing.T) {
  94		t.Parallel()
  95		items := []Item{createItem("test", 1)}
  96		l := New(items, WithDirectionBackward(), WithSize(20, 10)).(*list[Item])
  97		l.virtualHeight = 50
  98		
  99		// At the bottom (offset 0 in backward mode)
 100		l.offset = 0
 101		start, end := l.viewPosition()
 102		assert.Equal(t, 40, start)
 103		assert.Equal(t, 49, end)
 104		
 105		// In the middle
 106		l.offset = 20
 107		start, end = l.viewPosition()
 108		assert.Equal(t, 20, start)
 109		assert.Equal(t, 29, end)
 110		
 111		// Near the top
 112		l.offset = 40
 113		start, end = l.viewPosition()
 114		assert.Equal(t, 0, start)
 115		assert.Equal(t, 9, end)
 116		
 117		// Past the maximum valid offset (should be clamped)
 118		l.offset = 45
 119		start, end = l.viewPosition()
 120		assert.Equal(t, 0, start)
 121		assert.Equal(t, 9, end)
 122	})
 123	
 124	t.Run("backward direction - edge cases", func(t *testing.T) {
 125		t.Parallel()
 126		items := []Item{createItem("test", 1)}
 127		l := New(items, WithDirectionBackward(), WithSize(20, 10)).(*list[Item])
 128		l.virtualHeight = 5
 129		
 130		// Content smaller than viewport
 131		l.offset = 0
 132		start, end := l.viewPosition()
 133		assert.Equal(t, 0, start)
 134		assert.Equal(t, 4, end)
 135		
 136		// Any offset should show all content
 137		l.offset = 10
 138		start, end = l.viewPosition()
 139		assert.Equal(t, 0, start)
 140		assert.Equal(t, 4, end)
 141	})
 142}
 143
 144// Helper to create a test item with specific height
 145func createItem(id string, height int) Item {
 146		content := strings.Repeat(id+"\n", height)
 147		if height > 0 {
 148			content = strings.TrimSuffix(content, "\n")
 149		}
 150		item := &testItem{
 151			id:      id,
 152			content: content,
 153		}
 154	return item
 155}
 156
 157func TestRenderVirtualScrolling(t *testing.T) {
 158	t.Parallel()
 159	
 160	t.Run("should handle partially visible items at top", func(t *testing.T) {
 161		t.Parallel()
 162		items := []Item{
 163			createItem("A", 1),
 164			createItem("B", 5),
 165			createItem("C", 1),
 166			createItem("D", 3),
 167		}
 168		
 169		l := New(items, WithDirectionForward(), WithSize(20, 3)).(*list[Item])
 170		execCmd(l, l.Init())
 171		
 172		// Position B partially visible at top
 173		l.offset = 2 // Start viewing from line 2 (middle of B)
 174		l.calculateItemPositions()
 175		
 176		// Item positions: A(0-0), B(1-5), C(6-6), D(7-9)
 177		// Viewport: lines 2-4 (height=3)
 178		// Should show: lines 2-4 of B (3 lines from B)
 179		
 180		rendered := l.renderVirtualScrolling()
 181		lines := strings.Split(rendered, "\n")
 182		assert.Equal(t, 3, len(lines))
 183		assert.Equal(t, "B", lines[0])
 184		assert.Equal(t, "B", lines[1])
 185		assert.Equal(t, "B", lines[2])
 186	})
 187	
 188	t.Run("should handle gaps between items correctly", func(t *testing.T) {
 189		t.Parallel()
 190		items := []Item{
 191			createItem("A", 1),
 192			createItem("B", 1),
 193			createItem("C", 1),
 194		}
 195		
 196		l := New(items, WithDirectionForward(), WithSize(20, 5), WithGap(1)).(*list[Item])
 197		execCmd(l, l.Init())
 198		
 199		// Item positions: A(0-0), gap(1), B(2-2), gap(3), C(4-4)
 200		// Viewport: lines 0-4 (height=5)
 201		// Should show all items with gaps
 202		
 203		rendered := l.renderVirtualScrolling()
 204		lines := strings.Split(rendered, "\n")
 205		assert.Equal(t, 5, len(lines))
 206		assert.Equal(t, "A", lines[0])
 207		assert.Equal(t, "", lines[1]) // gap
 208		assert.Equal(t, "B", lines[2])
 209		assert.Equal(t, "", lines[3]) // gap
 210		assert.Equal(t, "C", lines[4])
 211	})
 212	
 213	t.Run("should not show empty lines when scrolled to bottom", func(t *testing.T) {
 214		t.Parallel()
 215		items := []Item{
 216			createItem("A", 2),
 217			createItem("B", 2),
 218			createItem("C", 2),
 219			createItem("D", 2),
 220			createItem("E", 2),
 221		}
 222		
 223		l := New(items, WithDirectionForward(), WithSize(20, 4)).(*list[Item])
 224		execCmd(l, l.Init())
 225		l.calculateItemPositions()
 226		
 227		// Total height: 10 lines (5 items * 2 lines each)
 228		// Scroll to show last 4 lines
 229		l.offset = 6
 230		
 231		rendered := l.renderVirtualScrolling()
 232		lines := strings.Split(rendered, "\n")
 233		assert.Equal(t, 4, len(lines))
 234		// Should show last 2 items completely
 235		assert.Equal(t, "D", lines[0])
 236		assert.Equal(t, "D", lines[1])
 237		assert.Equal(t, "E", lines[2])
 238		assert.Equal(t, "E", lines[3])
 239	})
 240	
 241	t.Run("should handle offset at maximum boundary", func(t *testing.T) {
 242		t.Parallel()
 243		items := []Item{
 244			createItem("A", 3),
 245			createItem("B", 3),
 246			createItem("C", 3),
 247			createItem("D", 3),
 248		}
 249		
 250		l := New(items, WithDirectionForward(), WithSize(20, 5)).(*list[Item])
 251		execCmd(l, l.Init())
 252		l.calculateItemPositions()
 253		
 254		// Total height: 12 lines
 255		// Max valid offset: 12 - 5 = 7
 256		l.offset = 7
 257		
 258		rendered := l.renderVirtualScrolling()
 259		lines := strings.Split(rendered, "\n")
 260		assert.Equal(t, 5, len(lines))
 261		// Should show from line 7 to 11
 262		assert.Contains(t, rendered, "C")
 263		assert.Contains(t, rendered, "D")
 264		
 265		// Try setting offset beyond max - should be clamped
 266		l.offset = 20
 267		rendered = l.renderVirtualScrolling()
 268		lines = strings.Split(rendered, "\n")
 269		assert.Equal(t, 5, len(lines))
 270		// Should still show the same content as offset=7
 271		assert.Contains(t, rendered, "C")
 272		assert.Contains(t, rendered, "D")
 273	})
 274}
 275
 276// testItem is a simple implementation of Item for testing
 277type testItem struct {
 278	id      string
 279	content string
 280}
 281
 282func (t *testItem) ID() string {
 283	return t.id
 284}
 285
 286func (t *testItem) View() string {
 287	return t.content
 288}
 289
 290func (t *testItem) Selectable() bool {
 291	return true
 292}
 293
 294func (t *testItem) Height() int {
 295	return lipgloss.Height(t.content)
 296}
 297
 298func (t *testItem) Init() tea.Cmd {
 299	return nil
 300}
 301
 302func (t *testItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 303	return t, nil
 304}
 305
 306func (t *testItem) SetSize(width, height int) tea.Cmd {
 307	return nil
 308}
 309
 310func (t *testItem) GetSize() (int, int) {
 311	return 0, lipgloss.Height(t.content)
 312}
 313
 314func (t *testItem) SetFocused(focused bool) tea.Cmd {
 315	return nil
 316}
 317
 318func (t *testItem) Focused() bool {
 319	return false
 320}
 321
 322func TestList(t *testing.T) {
 323	t.Parallel()
 324	t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
 325		t.Parallel()
 326		items := []Item{}
 327		for i := range 5 {
 328			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 329			items = append(items, item)
 330		}
 331		l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
 332		execCmd(l, l.Init())
 333
 334		// should select item 10
 335		assert.Equal(t, items[0].ID(), l.SelectedItemID())
 336		assert.Equal(t, 0, l.offset)
 337		require.Equal(t, 5, l.indexMap.Len())
 338		require.Equal(t, 5, l.items.Len())
 339		require.Equal(t, 5, len(l.itemPositions))
 340		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 341		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 342		start, end := l.viewPosition()
 343		assert.Equal(t, 0, start)
 344		assert.Equal(t, 4, end)
 345		for i := range 5 {
 346			item := l.itemPositions[i]
 347			assert.Equal(t, i, item.start)
 348			assert.Equal(t, i, item.end)
 349		}
 350
 351		golden.RequireEqual(t, []byte(l.View()))
 352	})
 353	t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
 354		t.Parallel()
 355		items := []Item{}
 356		for i := range 5 {
 357			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 358			items = append(items, item)
 359		}
 360		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
 361		execCmd(l, l.Init())
 362
 363		// should select item 10
 364		assert.Equal(t, items[4].ID(), l.SelectedItemID())
 365		assert.Equal(t, 0, l.offset)
 366		require.Equal(t, 5, l.indexMap.Len())
 367		require.Equal(t, 5, l.items.Len())
 368		require.Equal(t, 5, len(l.itemPositions))
 369		assert.Equal(t, 5, lipgloss.Height(l.rendered))
 370		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 371		start, end := l.viewPosition()
 372		assert.Equal(t, 0, start)
 373		assert.Equal(t, 4, end)
 374		for i := range 5 {
 375			item := l.itemPositions[i]
 376			assert.Equal(t, i, item.start)
 377			assert.Equal(t, i, item.end)
 378		}
 379
 380		golden.RequireEqual(t, []byte(l.View()))
 381	})
 382
 383	t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
 384		t.Parallel()
 385		items := []Item{}
 386		for i := range 30 {
 387			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 388			items = append(items, item)
 389		}
 390		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 391		execCmd(l, l.Init())
 392
 393		// should select item 10
 394		assert.Equal(t, items[0].ID(), l.SelectedItemID())
 395		assert.Equal(t, 0, l.offset)
 396		require.Equal(t, 30, l.indexMap.Len())
 397		require.Equal(t, 30, l.items.Len())
 398		require.Equal(t, 30, len(l.itemPositions))
 399		// With virtual scrolling, rendered height should be viewport height (10)
 400		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 401		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 402		start, end := l.viewPosition()
 403		assert.Equal(t, 0, start)
 404		assert.Equal(t, 9, end)
 405		for i := range 30 {
 406			item := l.itemPositions[i]
 407			assert.Equal(t, i, item.start)
 408			assert.Equal(t, i, item.end)
 409		}
 410
 411		golden.RequireEqual(t, []byte(l.View()))
 412	})
 413	t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
 414		t.Parallel()
 415		items := []Item{}
 416		for i := range 30 {
 417			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 418			items = append(items, item)
 419		}
 420		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 421		execCmd(l, l.Init())
 422
 423		// should select item 10
 424		assert.Equal(t, items[29].ID(), l.SelectedItemID())
 425		assert.Equal(t, 0, l.offset)
 426		require.Equal(t, 30, l.indexMap.Len())
 427		require.Equal(t, 30, l.items.Len())
 428		require.Equal(t, 30, len(l.itemPositions))
 429		// With virtual scrolling, rendered height should be viewport height (10)
 430		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 431		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 432		start, end := l.viewPosition()
 433		assert.Equal(t, 20, start)
 434		assert.Equal(t, 29, end)
 435		for i := range 30 {
 436			item := l.itemPositions[i]
 437			assert.Equal(t, i, item.start)
 438			assert.Equal(t, i, item.end)
 439		}
 440
 441		golden.RequireEqual(t, []byte(l.View()))
 442	})
 443
 444	t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
 445		t.Parallel()
 446		items := []Item{}
 447		for i := range 30 {
 448			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 449			content = strings.TrimSuffix(content, "\n")
 450			item := NewSelectableItem(content)
 451			items = append(items, item)
 452		}
 453		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 454		execCmd(l, l.Init())
 455
 456		// should select item 10
 457		assert.Equal(t, items[0].ID(), l.SelectedItemID())
 458		assert.Equal(t, 0, l.offset)
 459		require.Equal(t, 30, l.indexMap.Len())
 460		require.Equal(t, 30, l.items.Len())
 461		require.Equal(t, 30, len(l.itemPositions))
 462		expectedLines := 0
 463		for i := range 30 {
 464			expectedLines += (i + 1) * 1
 465		}
 466		// With virtual scrolling, rendered height should be viewport height (10)
 467		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 468		if len(l.rendered) > 0 {
 469			assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 470		}
 471		start, end := l.viewPosition()
 472		assert.Equal(t, 0, start)
 473		assert.Equal(t, 9, end)
 474		currentPosition := 0
 475		for i := range 30 {
 476			rItem := l.itemPositions[i]
 477			assert.Equal(t, currentPosition, rItem.start)
 478			assert.Equal(t, currentPosition+i, rItem.end)
 479			currentPosition += i + 1
 480		}
 481
 482		golden.RequireEqual(t, []byte(l.View()))
 483	})
 484	t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
 485		t.Parallel()
 486		items := []Item{}
 487		for i := range 30 {
 488			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 489			content = strings.TrimSuffix(content, "\n")
 490			item := NewSelectableItem(content)
 491			items = append(items, item)
 492		}
 493		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 494		execCmd(l, l.Init())
 495
 496		// should select item 10
 497		assert.Equal(t, items[29].ID(), l.SelectedItemID())
 498		assert.Equal(t, 0, l.offset)
 499		require.Equal(t, 30, l.indexMap.Len())
 500		require.Equal(t, 30, l.items.Len())
 501		require.Equal(t, 30, len(l.itemPositions))
 502		expectedLines := 0
 503		for i := range 30 {
 504			expectedLines += (i + 1) * 1
 505		}
 506		// With virtual scrolling, rendered height should be viewport height (10)
 507		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 508		if len(l.rendered) > 0 {
 509			assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
 510		}
 511		start, end := l.viewPosition()
 512		assert.Equal(t, expectedLines-10, start)
 513		assert.Equal(t, expectedLines-1, end)
 514		currentPosition := 0
 515		for i := range 30 {
 516			rItem := l.itemPositions[i]
 517			assert.Equal(t, currentPosition, rItem.start)
 518			assert.Equal(t, currentPosition+i, rItem.end)
 519			currentPosition += i + 1
 520		}
 521
 522		golden.RequireEqual(t, []byte(l.View()))
 523	})
 524
 525	t.Run("should go to selected item at the beginning", func(t *testing.T) {
 526		t.Parallel()
 527		items := []Item{}
 528		for i := range 30 {
 529			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 530			content = strings.TrimSuffix(content, "\n")
 531			item := NewSelectableItem(content)
 532			items = append(items, item)
 533		}
 534		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
 535		execCmd(l, l.Init())
 536
 537		// should select item 10
 538		assert.Equal(t, items[10].ID(), l.SelectedItemID())
 539
 540		golden.RequireEqual(t, []byte(l.View()))
 541	})
 542
 543	t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
 544		t.Parallel()
 545		items := []Item{}
 546		for i := range 30 {
 547			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 548			content = strings.TrimSuffix(content, "\n")
 549			item := NewSelectableItem(content)
 550			items = append(items, item)
 551		}
 552		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
 553		execCmd(l, l.Init())
 554
 555		// should select item 10
 556		assert.Equal(t, items[10].ID(), l.SelectedItemID())
 557
 558		golden.RequireEqual(t, []byte(l.View()))
 559	})
 560}
 561
 562func TestListMovement(t *testing.T) {
 563	t.Parallel()
 564	t.Run("should move viewport up", func(t *testing.T) {
 565		t.Parallel()
 566		items := []Item{}
 567		for i := range 30 {
 568			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 569			content = strings.TrimSuffix(content, "\n")
 570			item := NewSelectableItem(content)
 571			items = append(items, item)
 572		}
 573		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 574		execCmd(l, l.Init())
 575
 576		execCmd(l, l.MoveUp(25))
 577
 578		assert.Equal(t, 25, l.offset)
 579		golden.RequireEqual(t, []byte(l.View()))
 580	})
 581	t.Run("should move viewport up and down", func(t *testing.T) {
 582		t.Parallel()
 583		items := []Item{}
 584		for i := range 30 {
 585			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 586			content = strings.TrimSuffix(content, "\n")
 587			item := NewSelectableItem(content)
 588			items = append(items, item)
 589		}
 590		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 591		execCmd(l, l.Init())
 592
 593		execCmd(l, l.MoveUp(25))
 594		execCmd(l, l.MoveDown(25))
 595
 596		assert.Equal(t, 0, l.offset)
 597		golden.RequireEqual(t, []byte(l.View()))
 598	})
 599
 600	t.Run("should move viewport down", func(t *testing.T) {
 601		t.Parallel()
 602		items := []Item{}
 603		for i := range 30 {
 604			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 605			content = strings.TrimSuffix(content, "\n")
 606			item := NewSelectableItem(content)
 607			items = append(items, item)
 608		}
 609		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 610		execCmd(l, l.Init())
 611
 612		execCmd(l, l.MoveDown(25))
 613
 614		assert.Equal(t, 25, l.offset)
 615		golden.RequireEqual(t, []byte(l.View()))
 616	})
 617	t.Run("should move viewport down and up", func(t *testing.T) {
 618		t.Parallel()
 619		items := []Item{}
 620		for i := range 30 {
 621			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 622			content = strings.TrimSuffix(content, "\n")
 623			item := NewSelectableItem(content)
 624			items = append(items, item)
 625		}
 626		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 627		execCmd(l, l.Init())
 628
 629		execCmd(l, l.MoveDown(25))
 630		execCmd(l, l.MoveUp(25))
 631
 632		assert.Equal(t, 0, l.offset)
 633		golden.RequireEqual(t, []byte(l.View()))
 634	})
 635
 636	t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
 637		t.Parallel()
 638		items := []Item{}
 639		for i := range 30 {
 640			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 641			content = strings.TrimSuffix(content, "\n")
 642			item := NewSelectableItem(content)
 643			items = append(items, item)
 644		}
 645		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 646		execCmd(l, l.Init())
 647		execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
 648
 649		assert.Equal(t, 0, l.offset)
 650		golden.RequireEqual(t, []byte(l.View()))
 651	})
 652
 653	t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
 654		t.Parallel()
 655		items := []Item{}
 656		for i := range 30 {
 657			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 658			items = append(items, item)
 659		}
 660		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 661		execCmd(l, l.Init())
 662
 663		execCmd(l, l.MoveUp(2))
 664		viewBefore := l.View()
 665		execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
 666		viewAfter := l.View()
 667		assert.Equal(t, viewBefore, viewAfter)
 668		assert.Equal(t, 5, l.offset)
 669		// With virtual scrolling, rendered height should be viewport height (10)
 670		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 671		golden.RequireEqual(t, []byte(l.View()))
 672	})
 673	t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
 674		t.Parallel()
 675		items := []Item{}
 676		for i := range 30 {
 677			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 678			items = append(items, item)
 679		}
 680		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 681		execCmd(l, l.Init())
 682
 683		execCmd(l, l.MoveUp(2))
 684		viewBefore := l.View()
 685		item := items[29]
 686		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
 687		viewAfter := l.View()
 688		assert.Equal(t, viewBefore, viewAfter)
 689		assert.Equal(t, 4, l.offset)
 690		// With virtual scrolling, rendered height should be viewport height (10)
 691		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 692		golden.RequireEqual(t, []byte(l.View()))
 693	})
 694	t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
 695		t.Parallel()
 696		items := []Item{}
 697		for i := range 30 {
 698			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 699			items = append(items, item)
 700		}
 701		items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
 702		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 703		execCmd(l, l.Init())
 704
 705		execCmd(l, l.MoveUp(2))
 706		viewBefore := l.View()
 707		item := items[30]
 708		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
 709		viewAfter := l.View()
 710		assert.Equal(t, viewBefore, viewAfter)
 711		assert.Equal(t, 0, l.offset)
 712		// With virtual scrolling, rendered height should be viewport height (10)
 713		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 714		golden.RequireEqual(t, []byte(l.View()))
 715	})
 716	t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
 717		t.Parallel()
 718		items := []Item{}
 719		for i := range 30 {
 720			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 721			items = append(items, item)
 722		}
 723		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 724		execCmd(l, l.Init())
 725
 726		execCmd(l, l.MoveUp(2))
 727		viewBefore := l.View()
 728		item := items[1]
 729		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
 730		viewAfter := l.View()
 731		assert.Equal(t, viewBefore, viewAfter)
 732		assert.Equal(t, 2, l.offset)
 733		// With virtual scrolling, rendered height should be viewport height (10)
 734		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 735		golden.RequireEqual(t, []byte(l.View()))
 736	})
 737	t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
 738		t.Parallel()
 739		items := []Item{}
 740		for i := range 30 {
 741			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 742			items = append(items, item)
 743		}
 744		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
 745		execCmd(l, l.Init())
 746
 747		execCmd(l, l.MoveUp(2))
 748		viewBefore := l.View()
 749		execCmd(l, l.PrependItem(NewSelectableItem("New")))
 750		viewAfter := l.View()
 751		assert.Equal(t, viewBefore, viewAfter)
 752		assert.Equal(t, 2, l.offset)
 753		// With virtual scrolling, rendered height should be viewport height (10)
 754		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 755		golden.RequireEqual(t, []byte(l.View()))
 756	})
 757
 758	t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
 759		t.Parallel()
 760		items := []Item{}
 761		for i := range 30 {
 762			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
 763			content = strings.TrimSuffix(content, "\n")
 764			item := NewSelectableItem(content)
 765			items = append(items, item)
 766		}
 767		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 768		execCmd(l, l.Init())
 769		execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
 770
 771		assert.Equal(t, 0, l.offset)
 772		golden.RequireEqual(t, []byte(l.View()))
 773	})
 774
 775	t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
 776		t.Parallel()
 777		items := []Item{}
 778		for i := range 30 {
 779			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 780			items = append(items, item)
 781		}
 782		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 783		execCmd(l, l.Init())
 784
 785		execCmd(l, l.MoveDown(2))
 786		viewBefore := l.View()
 787		execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
 788		viewAfter := l.View()
 789		assert.Equal(t, viewBefore, viewAfter)
 790		assert.Equal(t, 5, l.offset)
 791		// With virtual scrolling, rendered height should be viewport height (10)
 792		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 793		golden.RequireEqual(t, []byte(l.View()))
 794	})
 795
 796	t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
 797		t.Parallel()
 798		items := []Item{}
 799		for i := range 30 {
 800			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 801			items = append(items, item)
 802		}
 803		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 804		execCmd(l, l.Init())
 805
 806		execCmd(l, l.MoveDown(2))
 807		viewBefore := l.View()
 808		item := items[0]
 809		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
 810		viewAfter := l.View()
 811		assert.Equal(t, viewBefore, viewAfter)
 812		assert.Equal(t, 4, l.offset)
 813		// With virtual scrolling, rendered height should be viewport height (10)
 814		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 815		golden.RequireEqual(t, []byte(l.View()))
 816	})
 817
 818	t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
 819		t.Parallel()
 820		items := []Item{}
 821		items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
 822		for i := range 30 {
 823			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 824			items = append(items, item)
 825		}
 826		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 827		execCmd(l, l.Init())
 828
 829		execCmd(l, l.MoveDown(3))
 830		viewBefore := l.View()
 831		item := items[0]
 832		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
 833		viewAfter := l.View()
 834		assert.Equal(t, viewBefore, viewAfter)
 835		assert.Equal(t, 1, l.offset)
 836		// With virtual scrolling, rendered height should be viewport height (10)
 837		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 838		golden.RequireEqual(t, []byte(l.View()))
 839	})
 840
 841	t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
 842		t.Parallel()
 843		items := []Item{}
 844		for i := range 30 {
 845			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 846			items = append(items, item)
 847		}
 848		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 849		execCmd(l, l.Init())
 850
 851		execCmd(l, l.MoveDown(2))
 852		viewBefore := l.View()
 853		item := items[29]
 854		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
 855		viewAfter := l.View()
 856		assert.Equal(t, viewBefore, viewAfter)
 857		assert.Equal(t, 2, l.offset)
 858		// With virtual scrolling, rendered height should be viewport height (10)
 859		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 860		golden.RequireEqual(t, []byte(l.View()))
 861	})
 862	t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
 863		t.Parallel()
 864		items := []Item{}
 865		for i := range 30 {
 866			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 867			items = append(items, item)
 868		}
 869		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
 870		execCmd(l, l.Init())
 871
 872		execCmd(l, l.MoveDown(2))
 873		viewBefore := l.View()
 874		execCmd(l, l.AppendItem(NewSelectableItem("New")))
 875		viewAfter := l.View()
 876		assert.Equal(t, viewBefore, viewAfter)
 877		assert.Equal(t, 2, l.offset)
 878		// With virtual scrolling, rendered height should be viewport height (10)
 879		assert.Equal(t, 10, lipgloss.Height(l.rendered))
 880		golden.RequireEqual(t, []byte(l.View()))
 881	})
 882
 883	t.Run("should scroll to top with SelectItemAbove and render 5 lines", func(t *testing.T) {
 884		t.Parallel()
 885		// Create 10 items
 886		items := []Item{}
 887		for i := range 10 {
 888			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 889			items = append(items, item)
 890		}
 891		
 892		// Create list with viewport of 5 lines height and 20 width, starting at the bottom (index 9)
 893		l := New(items, WithDirectionForward(), WithSize(20, 5), WithSelectedIndex(9)).(*list[Item])
 894		execCmd(l, l.Init())
 895		
 896		// Verify we start at the bottom (item 9 selected)
 897		assert.Equal(t, items[9].ID(), l.SelectedItemID())
 898		assert.Equal(t, 9, l.SelectedItemIndex())
 899		
 900		// Scroll to top one by one using SelectItemAbove
 901		for i := 8; i >= 0; i-- {
 902			execCmd(l, l.SelectItemAbove())
 903			assert.Equal(t, items[i].ID(), l.SelectedItemID())
 904			assert.Equal(t, i, l.SelectedItemIndex())
 905		}
 906		
 907		// Now we should be at the first item
 908		assert.Equal(t, items[0].ID(), l.SelectedItemID())
 909		assert.Equal(t, 0, l.SelectedItemIndex())
 910		
 911		// Verify the viewport is rendering exactly 5 lines
 912		rendered := l.View()
 913		
 914		// Check the height using lipgloss
 915		assert.Equal(t, 5, lipgloss.Height(rendered), "Should render exactly 5 lines")
 916		
 917		// Verify offset is at the top
 918		assert.Equal(t, 0, l.offset)
 919		
 920		// Verify the viewport position
 921		start, end := l.viewPosition()
 922		assert.Equal(t, 0, start, "View should start at position 0")
 923		assert.Equal(t, 4, end, "View should end at position 4")
 924	})
 925}
 926
 927type SelectableItem interface {
 928	Item
 929	layout.Focusable
 930}
 931
 932type simpleItem struct {
 933	width   int
 934	content string
 935	id      string
 936}
 937type selectableItem struct {
 938	*simpleItem
 939	focused bool
 940}
 941
 942func NewSimpleItem(content string) *simpleItem {
 943	return &simpleItem{
 944		id:      uuid.NewString(),
 945		width:   0,
 946		content: content,
 947	}
 948}
 949
 950func NewSelectableItem(content string) SelectableItem {
 951	return &selectableItem{
 952		simpleItem: NewSimpleItem(content),
 953		focused:    false,
 954	}
 955}
 956
 957func (s *simpleItem) ID() string {
 958	return s.id
 959}
 960
 961func (s *simpleItem) Init() tea.Cmd {
 962	return nil
 963}
 964
 965func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 966	return s, nil
 967}
 968
 969func (s *simpleItem) View() string {
 970	return lipgloss.NewStyle().Width(s.width).Render(s.content)
 971}
 972
 973func (l *simpleItem) GetSize() (int, int) {
 974	return l.width, 0
 975}
 976
 977// SetSize implements Item.
 978func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
 979	s.width = width
 980	return nil
 981}
 982
 983func (s *selectableItem) View() string {
 984	if s.focused {
 985		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
 986	}
 987	return lipgloss.NewStyle().Width(s.width).Render(s.content)
 988}
 989
 990// Blur implements SimpleItem.
 991func (s *selectableItem) Blur() tea.Cmd {
 992	s.focused = false
 993	return nil
 994}
 995
 996// Focus implements SimpleItem.
 997func (s *selectableItem) Focus() tea.Cmd {
 998	s.focused = true
 999	return nil
1000}
1001
1002// IsFocused implements SimpleItem.
1003func (s *selectableItem) IsFocused() bool {
1004	return s.focused
1005}
1006
1007func execCmd(m tea.Model, cmd tea.Cmd) {
1008	for cmd != nil {
1009		msg := cmd()
1010		m, cmd = m.Update(msg)
1011	}
1012}