wip: initial rework

Kujtim Hoxha created

Change summary

internal/tui/components/chat/chat.go                                                                                                                 |   2 
internal/tui/exp/list/filterable_test.go                                                                                                             | 124 
internal/tui/exp/list/list.go                                                                                                                        | 932 
internal/tui/exp/list/list_test.go                                                                                                                   | 706 
internal/tui/exp/list/testdata/TestBackwardList/more_than_height.golden                                                                              |   5 
internal/tui/exp/list/testdata/TestBackwardList/more_than_height_multi_line.golden                                                                   |   5 
internal/tui/exp/list/testdata/TestBackwardList/should_do_nothing_with_wrong_move_number.golden                                                      |   5 
internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden                                                                 |   5 
internal/tui/exp/list/testdata/TestBackwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden                                      |   5 
internal/tui/exp/list/testdata/TestBackwardList/should_move_to_the_top.golden                                                                        |   5 
internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden                                                                                |   5 
internal/tui/exp/list/testdata/TestBackwardList/should_select_the_item_above.golden                                                                  |   5 
internal/tui/exp/list/testdata/TestBackwardList/within_height.golden                                                                                 |   9 
internal/tui/exp/list/testdata/TestFilterableList/should_create_simple_filterable_list.golden                                                        |   6 
internal/tui/exp/list/testdata/TestForwardList/more_than_height.golden                                                                               |   5 
internal/tui/exp/list/testdata/TestForwardList/more_than_height_multi_line.golden                                                                    |   5 
internal/tui/exp/list/testdata/TestForwardList/should_do_nothing_with_wrong_move_number.golden                                                       |   5 
internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden                                                               |   5 
internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden                                                                               |   5 
internal/tui/exp/list/testdata/TestForwardList/should_move_the_view_to_be_able_to_see_the_selected_item.golden                                       |   5 
internal/tui/exp/list/testdata/TestForwardList/should_move_to_the_bottom.golden                                                                      |   5 
internal/tui/exp/list/testdata/TestForwardList/should_select_the_item_below.golden                                                                   |   5 
internal/tui/exp/list/testdata/TestForwardList/within_height.golden                                                                                  |   9 
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items.golden                                            |  10 
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden                   |  10 
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden         |  10 
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden                                  |  10 
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items.golden                                                     |   5 
internal/tui/exp/list/testdata/TestList/has_correct_positions_in_list_that_fits_the_items_backwards.golden                                           |   5 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center.golden                                                                 |  10 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_and_center_backwards.golden                                                       |  10 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning.golden                                                           |  10 
internal/tui/exp/list/testdata/TestList/should_go_to_selected_item_at_the_beginning_backwards.golden                                                 |  10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items.golden                                    |  10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items.golden           |  10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_and_has_multi_line_items_backwards.golden |  10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_does_not_fits_the_items_backwards.golden                          |  10 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items.golden                                             |   5 
internal/tui/exp/list/testdata/TestList/should_have_correct_positions_in_list_that_fits_the_items_backwards.golden                                   |   5 
internal/tui/exp/list/testdata/TestListChanges/should_append_an_item_to_the_end.golden                                                               |  10 
internal/tui/exp/list/testdata/TestListChanges/should_should_not_change_the_selected_if_we_moved_the_offset.golden                                   |  10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down.golden                                                                     |  10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_down_and_up.golden                                                              |  10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up.golden                                                                       |  10 
internal/tui/exp/list/testdata/TestListMovement/should_move_viewport_up_and_down.golden                                                              |  10 
internal/tui/exp/list/testdata/TestListSelection/should_select_the_correct_item_on_startup.golden                                                    |   5 
internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_in_the_middle.golden                                              |   7 
internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden                                                  |   6 
internal/tui/exp/list/testdata/TestListSetSelection/should_move_to_the_selected_item.golden                                                          |  10 
49 files changed, 892 insertions(+), 1,204 deletions(-)

Detailed changes

internal/tui/components/chat/chat.go 🔗

@@ -63,7 +63,7 @@ func New(app *app.App) MessageListCmp {
 	listCmp := list.New(
 		[]list.Item{},
 		list.WithGap(1),
-		list.WithDirection(list.Backward),
+		list.WithDirectionBackward(),
 		list.WithKeyMap(defaultListKeyMap),
 	)
 	return &messageListCmp{

internal/tui/exp/list/filterable_test.go 🔗

@@ -1,68 +1,60 @@
 package list
 
-import (
-	"fmt"
-	"slices"
-	"testing"
-
-	"github.com/charmbracelet/x/exp/golden"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestFilterableList(t *testing.T) {
-	t.Parallel()
-	t.Run("should create simple filterable list", func(t *testing.T) {
-		t.Parallel()
-		items := []FilterableItem{}
-		for i := range 5 {
-			item := NewFilterableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := NewFilterableList(
-			items,
-			WithFilterListOptions(WithDirection(Forward)),
-		).(*filterableList[FilterableItem])
-
-		l.SetSize(100, 10)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
-
-		assert.Equal(t, items[0].ID(), l.selectedItem)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-}
-
-func TestUpdateKeyMap(t *testing.T) {
-	t.Parallel()
-	l := NewFilterableList(
-		[]FilterableItem{},
-		WithFilterListOptions(WithDirection(Forward)),
-	).(*filterableList[FilterableItem])
-
-	hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
-	fmt.Println(l.keyMap.Down.Keys())
-	hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
-
-	hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
-
-	assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
-	assert.False(t, hasJ, "should not contain j")
-	assert.False(t, hasUpperCaseK, "should also remove upper case K")
-	assert.True(t, hasCtrlJ, "should still have ctrl+j")
-}
-
-type filterableItem struct {
-	*selectableItem
-}
-
-func NewFilterableItem(content string) FilterableItem {
-	return &filterableItem{
-		selectableItem: NewSelectableItem(content).(*selectableItem),
-	}
-}
-
-func (f *filterableItem) FilterValue() string {
-	return f.content
-}
+//
+// func TestFilterableList(t *testing.T) {
+// 	t.Parallel()
+// 	t.Run("should create simple filterable list", func(t *testing.T) {
+// 		t.Parallel()
+// 		items := []FilterableItem{}
+// 		for i := range 5 {
+// 			item := NewFilterableItem(fmt.Sprintf("Item %d", i))
+// 			items = append(items, item)
+// 		}
+// 		l := NewFilterableList(
+// 			items,
+// 			WithFilterListOptions(WithDirection(Forward)),
+// 		).(*filterableList[FilterableItem])
+//
+// 		l.SetSize(100, 10)
+// 		cmd := l.Init()
+// 		if cmd != nil {
+// 			cmd()
+// 		}
+//
+// 		assert.Equal(t, items[0].ID(), l.selectedItem)
+// 		golden.RequireEqual(t, []byte(l.View()))
+// 	})
+// }
+//
+// func TestUpdateKeyMap(t *testing.T) {
+// 	t.Parallel()
+// 	l := NewFilterableList(
+// 		[]FilterableItem{},
+// 		WithFilterListOptions(WithDirection(Forward)),
+// 	).(*filterableList[FilterableItem])
+//
+// 	hasJ := slices.Contains(l.keyMap.Down.Keys(), "j")
+// 	fmt.Println(l.keyMap.Down.Keys())
+// 	hasCtrlJ := slices.Contains(l.keyMap.Down.Keys(), "ctrl+j")
+//
+// 	hasUpperCaseK := slices.Contains(l.keyMap.UpOneItem.Keys(), "K")
+//
+// 	assert.False(t, l.keyMap.HalfPageDown.Enabled(), "should disable keys that are only letters")
+// 	assert.False(t, hasJ, "should not contain j")
+// 	assert.False(t, hasUpperCaseK, "should also remove upper case K")
+// 	assert.True(t, hasCtrlJ, "should still have ctrl+j")
+// }
+//
+// type filterableItem struct {
+// 	*selectableItem
+// }
+//
+// func NewFilterableItem(content string) FilterableItem {
+// 	return &filterableItem{
+// 		selectableItem: NewSelectableItem(content).(*selectableItem),
+// 	}
+// }
+//
+// func (f *filterableItem) FilterValue() string {
+// 	return f.content
+// }

internal/tui/exp/list/list.go 🔗

@@ -1,7 +1,6 @@
 package list
 
 import (
-	"slices"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
@@ -17,46 +16,50 @@ type Item interface {
 	ID() string
 }
 
-type List[T Item] interface {
-	util.Model
-	layout.Sizeable
-	layout.Focusable
-	MoveUp(int) tea.Cmd
-	MoveDown(int) tea.Cmd
-	GoToTop() tea.Cmd
-	GoToBottom() tea.Cmd
-	SelectItemAbove() tea.Cmd
-	SelectItemBelow() tea.Cmd
-	SetItems([]T) tea.Cmd
-	SetSelected(string) tea.Cmd
-	SelectedItem() *T
-	Items() []T
-	UpdateItem(string, T)
-	DeleteItem(string)
-	PrependItem(T) tea.Cmd
-	AppendItem(T) tea.Cmd
-}
+type (
+	renderedMsg  struct{}
+	List[T Item] interface {
+		util.Model
+		layout.Sizeable
+		layout.Focusable
+
+		// Just change state
+		MoveUp(int) tea.Cmd
+		MoveDown(int) tea.Cmd
+		GoToTop() tea.Cmd
+		GoToBottom() tea.Cmd
+		SelectItemAbove() tea.Cmd
+		SelectItemBelow() tea.Cmd
+		SetItems([]T) tea.Cmd
+		SetSelected(string) tea.Cmd
+		SelectedItem() *T
+		Items() []T
+		UpdateItem(string, T) tea.Cmd
+		DeleteItem(string) tea.Cmd
+		PrependItem(T) tea.Cmd
+		AppendItem(T) tea.Cmd
+	}
+)
 
 type direction int
 
 const (
-	Forward direction = iota
-	Backward
+	DirectionForward direction = iota
+	DirectionBackward
 )
 
 const (
-	NotFound          = -1
-	DefaultScrollSize = 2
+	ItemNotFound              = -1
+	ViewportDefaultScrollSize = 2
 )
 
-type setSelectedMsg struct {
-	selectedItemID string
-}
-
 type renderedItem struct {
 	id     string
 	view   string
+	dirty  bool
 	height int
+	start  int
+	end    int
 }
 
 type confOptions struct {
@@ -67,16 +70,20 @@ type confOptions struct {
 	keyMap       KeyMap
 	direction    direction
 	selectedItem string
+	focused      bool
 }
+
 type list[T Item] struct {
 	*confOptions
 
-	focused       bool
-	offset        int
-	items         []T
-	renderedItems []renderedItem
-	rendered      string
-	isReady       bool
+	offset int
+
+	indexMap map[string]int
+	items    []T
+
+	renderedItems map[string]renderedItem
+
+	rendered string
 }
 
 type listOption func(*confOptions)
@@ -96,10 +103,17 @@ func WithGap(gap int) listOption {
 	}
 }
 
-// WithDirection sets the direction of the list.
-func WithDirection(dir direction) listOption {
+// WithDirectionForward sets the direction to forward
+func WithDirectionForward() listOption {
+	return func(l *confOptions) {
+		l.direction = DirectionForward
+	}
+}
+
+// WithDirectionBackward sets the direction to forward
+func WithDirectionBackward() listOption {
 	return func(l *confOptions) {
-		l.direction = dir
+		l.direction = DirectionBackward
 	}
 }
 
@@ -122,55 +136,60 @@ func WithWrapNavigation() listOption {
 	}
 }
 
+func WithFocus(focus bool) listOption {
+	return func(l *confOptions) {
+		l.focused = focus
+	}
+}
+
 func New[T Item](items []T, opts ...listOption) List[T] {
 	list := &list[T]{
 		confOptions: &confOptions{
-			direction: Forward,
+			direction: DirectionForward,
 			keyMap:    DefaultKeyMap(),
+			focused:   true,
 		},
-		items: items,
+		items:         items,
+		indexMap:      make(map[string]int),
+		renderedItems: map[string]renderedItem{},
 	}
 	for _, opt := range opts {
 		opt(list.confOptions)
 	}
+
+	for inx, item := range items {
+		list.indexMap[item.ID()] = inx
+	}
 	return list
 }
 
 // Init implements List.
 func (l *list[T]) Init() tea.Cmd {
-	var cmds []tea.Cmd
-	for _, item := range l.items {
-		cmd := item.Init()
-		cmds = append(cmds, cmd)
-	}
-	cmds = append(cmds, l.renderItems())
-	return tea.Batch(cmds...)
+	return l.render()
 }
 
 // Update implements List.
 func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case setSelectedMsg:
-		return l, l.SetSelected(msg.selectedItemID)
 	case tea.KeyPressMsg:
 		if l.focused {
 			switch {
 			case key.Matches(msg, l.keyMap.Down):
-				return l, l.MoveDown(DefaultScrollSize)
+				return l, l.MoveDown(ViewportDefaultScrollSize)
 			case key.Matches(msg, l.keyMap.Up):
-				return l, l.MoveUp(DefaultScrollSize)
+				return l, l.MoveUp(ViewportDefaultScrollSize)
 			case key.Matches(msg, l.keyMap.DownOneItem):
 				return l, l.SelectItemBelow()
 			case key.Matches(msg, l.keyMap.UpOneItem):
 				return l, l.SelectItemAbove()
 			case key.Matches(msg, l.keyMap.HalfPageDown):
-				return l, l.MoveDown(l.listHeight() / 2)
+				return l, l.MoveDown(l.height / 2)
 			case key.Matches(msg, l.keyMap.HalfPageUp):
-				return l, l.MoveUp(l.listHeight() / 2)
+				return l, l.MoveUp(l.height / 2)
 			case key.Matches(msg, l.keyMap.PageDown):
-				return l, l.MoveDown(l.listHeight())
+				return l, l.MoveDown(l.height)
 			case key.Matches(msg, l.keyMap.PageUp):
-				return l, l.MoveUp(l.listHeight())
+				return l, l.MoveUp(l.height)
 			case key.Matches(msg, l.keyMap.End):
 				return l, l.GoToBottom()
 			case key.Matches(msg, l.keyMap.Home):
@@ -197,585 +216,506 @@ func (l *list[T]) View() string {
 func (l *list[T]) viewPosition() (int, int) {
 	start, end := 0, 0
 	renderedLines := lipgloss.Height(l.rendered) - 1
-	if l.direction == Forward {
+	if l.direction == DirectionForward {
 		start = max(0, l.offset)
-		end = min(l.offset+l.listHeight()-1, renderedLines)
+		end = min(l.offset+l.height-1, renderedLines)
 	} else {
-		start = max(0, renderedLines-l.offset-l.listHeight()+1)
+		start = max(0, renderedLines-l.offset-l.height+1)
 		end = max(0, renderedLines-l.offset)
 	}
 	return start, end
 }
 
-func (l *list[T]) renderItem(item Item) renderedItem {
-	view := item.View()
-	return renderedItem{
-		id:     item.ID(),
-		view:   view,
-		height: lipgloss.Height(view),
+func (l *list[T]) recalculateItemPositions() {
+	currentContentHeight := 0
+	for _, item := range l.items {
+		rItem, ok := l.renderedItems[item.ID()]
+		if !ok {
+			continue
+		}
+		rItem.start = currentContentHeight
+		rItem.end = currentContentHeight + rItem.height - 1
+		l.renderedItems[item.ID()] = rItem
+		currentContentHeight = rItem.end + 1 + l.gap
 	}
 }
 
-func (l *list[T]) renderView() {
-	var sb strings.Builder
-	for i, rendered := range l.renderedItems {
-		sb.WriteString(rendered.view)
-		if i < len(l.renderedItems)-1 {
-			sb.WriteString(strings.Repeat("\n", l.gap+1))
+func (l *list[T]) render() tea.Cmd {
+	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
+		return nil
+	}
+	l.setDefaultSelected()
+	focusCmd := l.focusSelectedItem()
+	// we are not rendering the first time
+	if l.rendered != "" {
+		l.rendered = ""
+		// rerender everything will mostly hit cache
+		_ = l.renderIterator(0, false)
+		if l.direction == DirectionBackward {
+			l.recalculateItemPositions()
+		}
+		// in the end scroll to the selected item
+		if l.focused {
+			l.scrollToSelection()
+		}
+		return focusCmd
+	}
+	finishIndex := l.renderIterator(0, true)
+	// recalculate for the initial items
+	if l.direction == DirectionBackward {
+		l.recalculateItemPositions()
+	}
+	renderCmd := func() tea.Msg {
+		// render the rest
+		_ = l.renderIterator(finishIndex, false)
+		// needed for backwards
+		if l.direction == DirectionBackward {
+			l.recalculateItemPositions()
 		}
+		// in the end scroll to the selected item
+		if l.focused {
+			l.scrollToSelection()
+		}
+		return renderedMsg{}
 	}
-	l.rendered = sb.String()
+	return tea.Batch(focusCmd, renderCmd)
 }
 
-func (l *list[T]) incrementOffset(n int) {
-	if !l.isReady {
-		return
+func (l *list[T]) setDefaultSelected() {
+	if l.selectedItem == "" {
+		if l.direction == DirectionForward {
+			l.selectFirstItem()
+		} else {
+			l.selectLastItem()
+		}
 	}
-	renderedHeight := lipgloss.Height(l.rendered)
-	// no need for offset
-	if renderedHeight <= l.listHeight() {
+}
+
+func (l *list[T]) scrollToSelection() {
+	rItem, ok := l.renderedItems[l.selectedItem]
+	if !ok {
+		l.selectedItem = ""
+		l.setDefaultSelected()
 		return
 	}
-	maxOffset := renderedHeight - l.listHeight()
-	n = min(n, maxOffset-l.offset)
-	if n <= 0 {
+
+	start, end := l.viewPosition()
+	// item bigger or equal to the viewport do nothing
+	if rItem.start <= start && rItem.end >= end {
 		return
 	}
-	l.offset += n
-}
-
-func (l *list[T]) decrementOffset(n int) {
-	if !l.isReady {
+	// item already in view do nothing
+	if rItem.start >= start && rItem.start <= end {
+		return
+	} else if rItem.end <= end && rItem.end >= start {
 		return
 	}
-	n = min(n, l.offset)
-	if n <= 0 {
+
+	if rItem.height >= l.height {
+		if l.direction == DirectionForward {
+			l.offset = rItem.start
+		} else {
+			l.offset = max(0, lipgloss.Height(l.rendered)-(rItem.start+l.height))
+		}
 		return
 	}
-	l.offset -= n
-	if l.offset < 0 {
-		l.offset = 0
+
+	itemMiddleStart := rItem.start + rItem.height/2 + 1
+	if l.direction == DirectionForward {
+		l.offset = itemMiddleStart - l.height/2
+	} else {
+		l.offset = max(0, lipgloss.Height(l.rendered)-(itemMiddleStart+l.height/2))
 	}
 }
 
-// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
-func (l *list[T]) changeSelectedWhenNotVisible() tea.Cmd {
-	var cmds []tea.Cmd
+func (l *list[T]) changeSelectionWhenScrolling() tea.Cmd {
+	rItem, ok := l.renderedItems[l.selectedItem]
+	if !ok {
+		return nil
+	}
 	start, end := l.viewPosition()
-	currentPosition := 0
-	itemWithinView := NotFound
-	needsMove := false
-
-	for i, item := range l.items {
-		rendered := l.renderedItems[i]
-		itemStart := currentPosition
-		// we remove 1 so that we actually have the row, e.x 1 row => height 1 => start 0, end 0
-		itemEnd := itemStart + rendered.height - 1
-		if itemStart >= start && itemEnd <= end {
-			itemWithinView = i
-		}
-		if item.ID() == l.selectedItem {
-			// item is completely above the viewport
-			if itemStart < start && itemEnd < start {
-				needsMove = true
+	// item bigger than the viewport do nothing
+	if rItem.start <= start && rItem.end >= end {
+		return nil
+	}
+	// item already in view do nothing
+	if rItem.start >= start && rItem.end <= end {
+		return nil
+	}
+
+	itemMiddle := rItem.start + rItem.height/2
+
+	if itemMiddle < start {
+		// select the first item in the viewport
+		// the item is most likely an item coming after this item
+		inx := l.indexMap[rItem.id]
+		for {
+			inx = l.firstSelectableItemBelow(inx)
+			if inx == ItemNotFound {
+				return nil
 			}
-			// item is completely below the viewport
-			if itemStart > end && itemEnd > end {
-				needsMove = true
+			item, ok := l.renderedItems[l.items[inx].ID()]
+			if !ok {
+				continue
 			}
-			if needsMove {
-				if focusable, ok := any(item).(layout.Focusable); ok {
-					cmds = append(cmds, focusable.Blur())
-				}
-				l.renderedItems[i] = l.renderItem(item)
-			} else {
-				return nil
+
+			// If the item is bigger than the viewport, select it
+			if item.start <= start && item.end >= end {
+				l.selectedItem = item.id
+				return l.render()
+			}
+			// item is in the view
+			if item.start >= start && item.start <= end {
+				l.selectedItem = item.id
+				return l.render()
 			}
 		}
-		if itemWithinView != NotFound && needsMove {
-			newSelection := l.items[itemWithinView]
-			l.selectedItem = newSelection.ID()
-			if focusable, ok := any(newSelection).(layout.Focusable); ok {
-				cmds = append(cmds, focusable.Focus())
+	} else if itemMiddle > end {
+		// select the first item in the viewport
+		// the item is most likely an item coming after this item
+		inx := l.indexMap[rItem.id]
+		for {
+			inx = l.firstSelectableItemAbove(inx)
+			if inx == ItemNotFound {
+				return nil
+			}
+			item, ok := l.renderedItems[l.items[inx].ID()]
+			if !ok {
+				continue
+			}
+
+			// If the item is bigger than the viewport, select it
+			if item.start <= start && item.end >= end {
+				l.selectedItem = item.id
+				return l.render()
+			}
+			// item is in the view
+			if item.end >= start && item.end <= end {
+				l.selectedItem = item.id
+				return l.render()
 			}
-			l.renderedItems[itemWithinView] = l.renderItem(newSelection)
-			break
 		}
-		currentPosition += rendered.height + l.gap
 	}
-	l.renderView()
-	return tea.Batch(cmds...)
+	return nil
 }
 
-func (l *list[T]) MoveUp(n int) tea.Cmd {
-	if l.direction == Forward {
-		l.decrementOffset(n)
-	} else {
-		l.incrementOffset(n)
+func (l *list[T]) selectFirstItem() {
+	inx := l.firstSelectableItemBelow(-1)
+	if inx != ItemNotFound {
+		l.selectedItem = l.items[inx].ID()
 	}
-	return l.changeSelectedWhenNotVisible()
 }
 
-func (l *list[T]) MoveDown(n int) tea.Cmd {
-	if l.direction == Forward {
-		l.incrementOffset(n)
-	} else {
-		l.decrementOffset(n)
+func (l *list[T]) selectLastItem() {
+	inx := l.firstSelectableItemAbove(len(l.items))
+	if inx != ItemNotFound {
+		l.selectedItem = l.items[inx].ID()
 	}
-	return l.changeSelectedWhenNotVisible()
 }
 
-func (l *list[T]) firstSelectableItemBefore(inx int) int {
+func (l *list[T]) firstSelectableItemAbove(inx int) int {
 	for i := inx - 1; i >= 0; i-- {
 		if _, ok := any(l.items[i]).(layout.Focusable); ok {
 			return i
 		}
 	}
 	if inx == 0 && l.wrap {
-		return l.firstSelectableItemBefore(len(l.items))
+		return l.firstSelectableItemAbove(len(l.items))
 	}
-	return NotFound
+	return ItemNotFound
 }
 
-func (l *list[T]) firstSelectableItemAfter(inx int) int {
+func (l *list[T]) firstSelectableItemBelow(inx int) int {
 	for i := inx + 1; i < len(l.items); i++ {
 		if _, ok := any(l.items[i]).(layout.Focusable); ok {
 			return i
 		}
 	}
 	if inx == len(l.items)-1 && l.wrap {
-		return l.firstSelectableItemAfter(-1)
+		return l.firstSelectableItemBelow(-1)
 	}
-	return NotFound
+	return ItemNotFound
 }
 
-// moveToSelected needs to be called after the view is rendered
-func (l *list[T]) moveToSelected(center bool) tea.Cmd {
-	var cmds []tea.Cmd
-	if l.selectedItem == "" || !l.isReady {
+func (l *list[T]) focusSelectedItem() tea.Cmd {
+	if l.selectedItem == "" || !l.focused {
 		return nil
 	}
-	currentPosition := 0
-	start, end := l.viewPosition()
-	for _, item := range l.renderedItems {
-		if item.id == l.selectedItem {
-			itemStart := currentPosition
-			itemEnd := currentPosition + item.height - 1
-
-			if start <= itemStart && itemEnd <= end {
-				return nil
-			}
-
-			if center {
-				viewportCenter := l.listHeight() / 2
-				itemCenter := itemStart + item.height/2
-				targetOffset := itemCenter - viewportCenter
-				if l.direction == Forward {
-					if targetOffset > l.offset {
-						cmds = append(cmds, l.MoveDown(targetOffset-l.offset))
-					} else if targetOffset < l.offset {
-						cmds = append(cmds, l.MoveUp(l.offset-targetOffset))
-					}
-				} else {
-					renderedHeight := lipgloss.Height(l.rendered)
-					backwardTargetOffset := renderedHeight - targetOffset - l.listHeight()
-					if backwardTargetOffset > l.offset {
-						cmds = append(cmds, l.MoveUp(backwardTargetOffset-l.offset))
-					} else if backwardTargetOffset < l.offset {
-						cmds = append(cmds, l.MoveDown(l.offset-backwardTargetOffset))
-					}
-				}
-			} else {
-				if currentPosition < start {
-					cmds = append(cmds, l.MoveUp(start-currentPosition))
+	var cmds []tea.Cmd
+	for _, item := range l.items {
+		if f, ok := any(item).(layout.Focusable); ok {
+			if item.ID() == l.selectedItem && !f.IsFocused() {
+				cmds = append(cmds, f.Focus())
+				if cache, ok := l.renderedItems[item.ID()]; ok {
+					cache.dirty = true
+					l.renderedItems[item.ID()] = cache
 				}
-				if currentPosition > end {
-					cmds = append(cmds, l.MoveDown(currentPosition-end))
+			} else if item.ID() != l.selectedItem && f.IsFocused() {
+				cmds = append(cmds, f.Blur())
+				if cache, ok := l.renderedItems[item.ID()]; ok {
+					cache.dirty = true
+					l.renderedItems[item.ID()] = cache
 				}
 			}
 		}
-		currentPosition += item.height + l.gap
 	}
 	return tea.Batch(cmds...)
 }
 
-func (l *list[T]) SelectItemAbove() tea.Cmd {
-	if !l.isReady {
-		return nil
-	}
+func (l *list[T]) blurItems() tea.Cmd {
 	var cmds []tea.Cmd
-	for i, item := range l.items {
-		if l.selectedItem == item.ID() {
-			inx := l.firstSelectableItemBefore(i)
-			if inx == NotFound {
-				// no item above
-				return nil
-			}
-			// blur the current item
-			if focusable, ok := any(item).(layout.Focusable); ok {
-				cmds = append(cmds, focusable.Blur())
-			}
-			// rerender the item
-			l.renderedItems[i] = l.renderItem(item)
-			// focus the item above
-			above := l.items[inx]
-			if focusable, ok := any(above).(layout.Focusable); ok {
-				cmds = append(cmds, focusable.Focus())
+	for _, item := range l.items {
+		if f, ok := any(item).(layout.Focusable); ok {
+			if item.ID() == l.selectedItem && f.IsFocused() {
+				cmds = append(cmds, f.Blur())
+				if cache, ok := l.renderedItems[item.ID()]; ok {
+					cache.dirty = true
+					l.renderedItems[item.ID()] = cache
+				}
 			}
-			// rerender the item
-			l.renderedItems[inx] = l.renderItem(above)
-			l.selectedItem = above.ID()
-			break
 		}
 	}
-	l.renderView()
-	l.moveToSelected(false)
 	return tea.Batch(cmds...)
 }
 
-func (l *list[T]) SelectItemBelow() tea.Cmd {
-	if !l.isReady {
-		return nil
-	}
-	var cmds []tea.Cmd
-	for i, item := range l.items {
-		if l.selectedItem == item.ID() {
-			inx := l.firstSelectableItemAfter(i)
-			if inx == NotFound {
-				// no item below
-				return nil
-			}
-			// blur the current item
-			if focusable, ok := any(item).(layout.Focusable); ok {
-				cmds = append(cmds, focusable.Blur())
-			}
-			// rerender the item
-			l.renderedItems[i] = l.renderItem(item)
+// render iterator renders items starting from the specific index and limits hight if limitHeight != -1
+// returns the last index
+func (l *list[T]) renderIterator(startInx int, limitHeight bool) int {
+	currentContentHeight := lipgloss.Height(l.rendered) - 1
+	for i := startInx; i < len(l.items); i++ {
+		if currentContentHeight >= l.height && limitHeight {
+			return i
+		}
+		// cool way to go through the list in both directions
+		inx := i
 
-			// focus the item below
-			below := l.items[inx]
-			if focusable, ok := any(below).(layout.Focusable); ok {
-				cmds = append(cmds, focusable.Focus())
-			}
-			// rerender the item
-			l.renderedItems[inx] = l.renderItem(below)
-			l.selectedItem = below.ID()
-			break
+		if l.direction != DirectionForward {
+			inx = (len(l.items) - 1) - i
 		}
-	}
 
-	l.renderView()
-	l.moveToSelected(false)
-	return tea.Batch(cmds...)
-}
+		item := l.items[inx]
+		var rItem renderedItem
+		if cache, ok := l.renderedItems[item.ID()]; ok && !cache.dirty {
+			rItem = cache
+		} else {
+			rItem = l.renderItem(item)
+			rItem.start = currentContentHeight
+			rItem.end = currentContentHeight + rItem.height - 1
+			l.renderedItems[item.ID()] = rItem
+		}
+		gap := l.gap + 1
+		if inx == len(l.items)-1 {
+			gap = 0
+		}
 
-func (l *list[T]) GoToTop() tea.Cmd {
-	if !l.isReady {
-		return nil
+		if l.direction == DirectionForward {
+			l.rendered += rItem.view + strings.Repeat("\n", gap)
+		} else {
+			l.rendered = rItem.view + strings.Repeat("\n", gap) + l.rendered
+		}
+		currentContentHeight = rItem.end + 1 + l.gap
 	}
-	l.offset = 0
-	l.direction = Forward
-	return tea.Batch(l.selectFirstItem(), l.renderForward())
+	return len(l.items)
 }
 
-func (l *list[T]) GoToBottom() tea.Cmd {
-	if !l.isReady {
-		return nil
+func (l *list[T]) renderItem(item Item) renderedItem {
+	view := item.View()
+	return renderedItem{
+		id:     item.ID(),
+		view:   view,
+		height: lipgloss.Height(view),
 	}
-	l.offset = 0
-	l.direction = Backward
-
-	return tea.Batch(l.selectLastItem(), l.renderBackward())
 }
 
-func (l *list[T]) renderForward() tea.Cmd {
-	// TODO: figure out a way to preserve items that did not change
-	l.renderedItems = make([]renderedItem, 0)
-	currentHeight := 0
-	currentIndex := 0
-	for i, item := range l.items {
-		currentIndex = i
-		if currentHeight-1 > l.listHeight() {
-			break
-		}
-		rendered := l.renderItem(item)
-		l.renderedItems = append(l.renderedItems, rendered)
-		currentHeight += rendered.height + l.gap
-	}
+// AppendItem implements List.
+func (l *list[T]) AppendItem(T) tea.Cmd {
+	panic("unimplemented")
+}
 
-	// initial render
-	l.renderView()
+// Blur implements List.
+func (l *list[T]) Blur() tea.Cmd {
+	cmd := l.blurItems()
+	return tea.Batch(cmd, l.render())
+}
 
-	if currentIndex == len(l.items)-1 {
-		l.isReady = true
-		return nil
-	}
-	// render the rest
-	return func() tea.Msg {
-		for i := currentIndex; i < len(l.items); i++ {
-			rendered := l.renderItem(l.items[i])
-			l.renderedItems = append(l.renderedItems, rendered)
-		}
-		l.renderView()
-		l.isReady = true
-		return nil
-	}
+// DeleteItem implements List.
+func (l *list[T]) DeleteItem(string) tea.Cmd {
+	panic("unimplemented")
 }
 
-func (l *list[T]) renderBackward() tea.Cmd {
-	// TODO: figure out a way to preserve items that did not change
-	l.renderedItems = make([]renderedItem, 0)
-	currentHeight := 0
-	currentIndex := 0
-	for i := len(l.items) - 1; i >= 0; i-- {
-		currentIndex = i
-		if currentHeight > l.listHeight() {
-			break
-		}
-		rendered := l.renderItem(l.items[i])
-		l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
-		currentHeight += rendered.height + l.gap
-	}
-	// initial render
-	l.renderView()
-	if currentIndex == 0 {
-		l.isReady = true
-		return nil
-	}
-	return func() tea.Msg {
-		for i := currentIndex; i >= 0; i-- {
-			rendered := l.renderItem(l.items[i])
-			l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
-		}
-		l.renderView()
-		l.isReady = true
-		return nil
-	}
+// Focus implements List.
+func (l *list[T]) Focus() tea.Cmd {
+	l.focused = true
+	return l.render()
 }
 
-func (l *list[T]) selectFirstItem() tea.Cmd {
-	var cmd tea.Cmd
-	inx := l.firstSelectableItemAfter(-1)
-	if inx != NotFound {
-		l.selectedItem = l.items[inx].ID()
-		if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
-			cmd = focusable.Focus()
-		}
-	}
-	return cmd
+// GetSize implements List.
+func (l *list[T]) GetSize() (int, int) {
+	return l.width, l.height
 }
 
-func (l *list[T]) selectLastItem() tea.Cmd {
-	var cmd tea.Cmd
-	inx := l.firstSelectableItemBefore(len(l.items))
-	if inx != NotFound {
-		l.selectedItem = l.items[inx].ID()
-		if focusable, ok := any(l.items[inx]).(layout.Focusable); ok {
-			cmd = focusable.Focus()
-		}
-	}
-	return cmd
+// GoToBottom implements List.
+func (l *list[T]) GoToBottom() tea.Cmd {
+	l.offset = 0
+	l.direction = DirectionBackward
+	l.selectedItem = ""
+	return l.render()
 }
 
-func (l *list[T]) renderItems() tea.Cmd {
-	if l.height <= 0 || l.width <= 0 {
-		return nil
-	}
-	if len(l.items) == 0 {
-		return nil
-	}
+// GoToTop implements List.
+func (l *list[T]) GoToTop() tea.Cmd {
+	l.offset = 0
+	l.direction = DirectionForward
+	l.selectedItem = ""
+	return l.render()
+}
 
-	if l.selectedItem == "" {
-		if l.direction == Forward {
-			l.selectFirstItem()
-		} else {
-			l.selectLastItem()
-		}
-	}
-	if l.direction == Forward {
-		return l.renderForward()
-	}
-	return l.renderBackward()
+// IsFocused implements List.
+func (l *list[T]) IsFocused() bool {
+	return l.focused
 }
 
-func (l *list[T]) listHeight() int {
-	// for the moment its the same
-	return l.height
+// Items implements List.
+func (l *list[T]) Items() []T {
+	return l.items
 }
 
-func (l *list[T]) SetItems(items []T) tea.Cmd {
-	l.items = items
-	var cmds []tea.Cmd
-	for _, item := range l.items {
-		cmds = append(cmds, item.Init())
-		// Set height to 0 to let the item calculate its own height
-		cmds = append(cmds, item.SetSize(l.width, 0))
+func (l *list[T]) incrementOffset(n int) {
+	renderedHeight := lipgloss.Height(l.rendered)
+	// no need for offset
+	if renderedHeight <= l.height {
+		return
+	}
+	maxOffset := renderedHeight - l.height
+	n = min(n, maxOffset-l.offset)
+	if n <= 0 {
+		return
 	}
+	l.offset += n
+}
 
-	cmds = append(cmds, l.renderItems())
-	if l.selectedItem != "" {
-		cmds = append(cmds, l.moveToSelected(true))
+func (l *list[T]) decrementOffset(n int) {
+	n = min(n, l.offset)
+	if n <= 0 {
+		return
+	}
+	l.offset -= n
+	if l.offset < 0 {
+		l.offset = 0
 	}
-	return tea.Batch(cmds...)
 }
 
-// GetSize implements List.
-func (l *list[T]) GetSize() (int, int) {
-	return l.width, l.height
+// MoveDown implements List.
+func (l *list[T]) MoveDown(n int) tea.Cmd {
+	if l.direction == DirectionForward {
+		l.incrementOffset(n)
+	} else {
+		l.decrementOffset(n)
+	}
+	return l.changeSelectionWhenScrolling()
 }
 
-// SetSize implements List.
-func (l *list[T]) SetSize(width int, height int) tea.Cmd {
-	l.width = width
-	l.height = height
-	var cmds []tea.Cmd
-	for _, item := range l.items {
-		cmds = append(cmds, item.SetSize(width, height))
+// MoveUp implements List.
+func (l *list[T]) MoveUp(n int) tea.Cmd {
+	if l.direction == DirectionForward {
+		l.decrementOffset(n)
+	} else {
+		l.incrementOffset(n)
 	}
+	return l.changeSelectionWhenScrolling()
+}
 
-	cmds = append(cmds, l.renderItems())
-	return tea.Batch(cmds...)
+// PrependItem implements List.
+func (l *list[T]) PrependItem(T) tea.Cmd {
+	panic("unimplemented")
 }
 
-// Blur implements List.
-func (l *list[T]) Blur() tea.Cmd {
-	var cmd tea.Cmd
-	l.focused = false
-	for i, item := range l.items {
-		if item.ID() != l.selectedItem {
-			continue
-		}
-		if focusable, ok := any(item).(layout.Focusable); ok {
-			cmd = focusable.Blur()
-		}
-		l.renderedItems[i] = l.renderItem(item)
+// SelectItemAbove implements List.
+func (l *list[T]) SelectItemAbove() tea.Cmd {
+	inx, ok := l.indexMap[l.selectedItem]
+	if !ok {
+		return nil
 	}
-	l.renderView()
-	return cmd
-}
 
-// Focus implements List.
-func (l *list[T]) Focus() tea.Cmd {
-	var cmd tea.Cmd
-	l.focused = true
-	if l.selectedItem != "" {
-		for i, item := range l.items {
-			if item.ID() != l.selectedItem {
-				continue
-			}
-			if focusable, ok := any(item).(layout.Focusable); ok {
-				cmd = focusable.Focus()
-			}
-			if len(l.renderedItems) > i {
-				l.renderedItems[i] = l.renderItem(item)
-			}
-		}
-		l.renderView()
+	newIndex := l.firstSelectableItemAbove(inx)
+	if newIndex == ItemNotFound {
+		// no item above
+		return nil
 	}
-	return cmd
+	item := l.items[newIndex]
+	l.selectedItem = item.ID()
+	return l.render()
 }
 
-func (l *list[T]) SetSelected(id string) tea.Cmd {
-	if l.selectedItem == id {
+// SelectItemBelow implements List.
+func (l *list[T]) SelectItemBelow() tea.Cmd {
+	inx, ok := l.indexMap[l.selectedItem]
+	if !ok {
 		return nil
 	}
-	var cmds []tea.Cmd
-	for i, item := range l.items {
-		if item.ID() == l.selectedItem {
-			if focusable, ok := any(item).(layout.Focusable); ok {
-				cmds = append(cmds, focusable.Blur())
-			}
-			if len(l.renderedItems) > i {
-				l.renderedItems[i] = l.renderItem(item)
-			}
-		} else if item.ID() == id {
-			if focusable, ok := any(item).(layout.Focusable); ok {
-				cmds = append(cmds, focusable.Focus())
-			}
-			if len(l.renderedItems) > i {
-				l.renderedItems[i] = l.renderItem(item)
-			}
-		}
+
+	newIndex := l.firstSelectableItemBelow(inx)
+	if newIndex == ItemNotFound {
+		// no item above
+		return nil
 	}
-	l.selectedItem = id
-	l.renderView()
-	cmds = append(cmds, l.moveToSelected(true))
-	return tea.Batch(cmds...)
+	item := l.items[newIndex]
+	l.selectedItem = item.ID()
+	return l.render()
 }
 
+// SelectedItem implements List.
 func (l *list[T]) SelectedItem() *T {
-	for _, item := range l.items {
-		if item.ID() == l.selectedItem {
-			return &item
-		}
+	inx, ok := l.indexMap[l.selectedItem]
+	if !ok {
+		return nil
 	}
-	return nil
+	if inx > len(l.items)-1 {
+		return nil
+	}
+	item := l.items[inx]
+	return &item
 }
 
-// IsFocused implements List.
-func (l *list[T]) IsFocused() bool {
-	return l.focused
+// SetItems implements List.
+func (l *list[T]) SetItems(items []T) tea.Cmd {
+	l.items = items
+	return l.reset()
 }
 
-func (l *list[T]) Items() []T {
-	return l.items
+// SetSelected implements List.
+func (l *list[T]) SetSelected(id string) tea.Cmd {
+	l.selectedItem = id
+	return l.render()
 }
 
-func (l *list[T]) UpdateItem(id string, item T) {
-	// TODO: preserve offset
+func (l *list[T]) reset() tea.Cmd {
+	var cmds []tea.Cmd
+	l.rendered = ""
+	l.indexMap = make(map[string]int)
+	l.renderedItems = make(map[string]renderedItem)
 	for inx, item := range l.items {
-		if item.ID() == id {
-			l.items[inx] = item
-			l.renderedItems[inx] = l.renderItem(item)
-			l.renderView()
-			return
+		l.indexMap[item.ID()] = inx
+		if l.width > 0 && l.height > 0 {
+			cmds = append(cmds, item.SetSize(l.width, l.height))
 		}
 	}
+	cmds = append(cmds, l.render())
+	return tea.Batch(cmds...)
 }
 
-func (l *list[T]) DeleteItem(id string) {
-	// TODO: preserve offset
-	inx := NotFound
-	for i, item := range l.items {
-		if item.ID() == id {
-			inx = i
-			break
-		}
-	}
-
-	l.items = slices.Delete(l.items, inx, inx+1)
-	l.renderedItems = slices.Delete(l.renderedItems, inx, inx+1)
-	l.renderView()
-}
-
-func (l *list[T]) PrependItem(item T) tea.Cmd {
-	// TODO: preserve offset
-	var cmd tea.Cmd
-	l.items = append([]T{item}, l.items...)
-	l.renderedItems = append([]renderedItem{l.renderItem(item)}, l.renderedItems...)
-	if len(l.items) == 1 {
-		cmd = l.SetSelected(item.ID())
-	}
-	// the viewport did not move and the last item was focused
-	if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[0].ID() {
-		cmd = l.SetSelected(item.ID())
+// SetSize implements List.
+func (l *list[T]) SetSize(width int, height int) tea.Cmd {
+	oldWidth := l.width
+	l.width = width
+	l.height = height
+	if oldWidth != width {
+		return l.reset()
 	}
-	l.renderView()
-	return cmd
+	return nil
 }
 
-func (l *list[T]) AppendItem(item T) tea.Cmd {
-	// TODO: preserve offset
-	var cmd tea.Cmd
-	l.items = append(l.items, item)
-	l.renderedItems = append(l.renderedItems, l.renderItem(item))
-	if len(l.items) == 1 {
-		cmd = l.SetSelected(item.ID())
-	} else if l.direction == Backward && l.offset == 0 && l.selectedItem == l.items[len(l.items)-2].ID() {
-		// the viewport did not move and the last item was focused
-		cmd = l.SetSelected(item.ID())
-	} else {
-		l.renderView()
-	}
-	return cmd
+// UpdateItem implements List.
+func (l *list[T]) UpdateItem(string, T) tea.Cmd {
+	panic("unimplemented")
 }

internal/tui/exp/list/list_test.go 🔗

@@ -2,7 +2,7 @@ package list
 
 import (
 	"fmt"
-	"sync"
+	"strings"
 	"testing"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -11,623 +11,344 @@ import (
 	"github.com/charmbracelet/x/exp/golden"
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
-func TestListPosition(t *testing.T) {
+func TestList(t *testing.T) {
 	t.Parallel()
-	type positionOffsetTest struct {
-		dir      direction
-		test     string
-		width    int
-		height   int
-		numItems int
-
-		moveUp   int
-		moveDown int
-
-		expectedStart int
-		expectedEnd   int
-	}
-	tests := []positionOffsetTest{
-		{
-			dir:           Forward,
-			test:          "should have correct position initially when forward",
-			moveUp:        0,
-			moveDown:      0,
-			width:         10,
-			height:        20,
-			numItems:      100,
-			expectedStart: 0,
-			expectedEnd:   19,
-		},
-		{
-			dir:           Forward,
-			test:          "should offset start and end by one when moving down by one",
-			moveUp:        0,
-			moveDown:      1,
-			width:         10,
-			height:        20,
-			numItems:      100,
-			expectedStart: 1,
-			expectedEnd:   20,
-		},
-		{
-			dir:           Backward,
-			test:          "should have correct position initially when backward",
-			moveUp:        0,
-			moveDown:      0,
-			width:         10,
-			height:        20,
-			numItems:      100,
-			expectedStart: 80,
-			expectedEnd:   99,
-		},
-		{
-			dir:           Backward,
-			test:          "should offset the start and end by one when moving up by one",
-			moveUp:        1,
-			moveDown:      0,
-			width:         10,
-			height:        20,
-			numItems:      100,
-			expectedStart: 79,
-			expectedEnd:   98,
-		},
-	}
-	for _, c := range tests {
-		t.Run(c.test, func(t *testing.T) {
-			t.Parallel()
-			items := []Item{}
-			for i := range c.numItems {
-				item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-				items = append(items, item)
-			}
-			l := New(items, WithDirection(c.dir)).(*list[Item])
-			l.SetSize(c.width, c.height)
-			cmd := l.Init()
-			if cmd != nil {
-				cmd()
-			}
-
-			if c.moveUp > 0 {
-				l.MoveUp(c.moveUp)
-			}
-			if c.moveDown > 0 {
-				l.MoveDown(c.moveDown)
-			}
-			start, end := l.viewPosition()
-			assert.Equal(t, c.expectedStart, start)
-			assert.Equal(t, c.expectedEnd, end)
-		})
-	}
-}
-
-func TestBackwardList(t *testing.T) {
-	t.Parallel()
-	t.Run("within height", func(t *testing.T) {
+	t.Run("should have correct positions in list that fits the items", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
 		for i := range 5 {
 			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward), WithGap(1)).(*list[Item])
-		l.SetSize(10, 20)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionForward(), WithSize(10, 20)).(*list[Item])
+		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
+		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Len(t, l.indexMap, 5)
+		require.Len(t, l.items, 5)
+		require.Len(t, l.renderedItems, 5)
+		assert.Equal(t, 5, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 4, end)
+		for i := range 5 {
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
+		}
+
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should not change selected item", func(t *testing.T) {
+	t.Run("should have correct positions in list that fits the items backwards", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
 		for i := range 5 {
 			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
-		l.SetSize(10, 20)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 20)).(*list[Item])
+		execCmd(l, l.Init())
+
 		// should select the last item
-		assert.Equal(t, l.selectedItem, items[2].ID())
-	})
-	t.Run("more than height", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Backward))
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
+		assert.Equal(t, items[4].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Len(t, l.indexMap, 5)
+		require.Len(t, l.items, 5)
+		require.Len(t, l.renderedItems, 5)
+		assert.Equal(t, 5, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 4, end)
+		for i := range 5 {
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
 		}
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("more than height multi line", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Backward))
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
 
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should move up", func(t *testing.T) {
+	t.Run("should have correct positions in list that does not fits the items", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 10 {
+		for i := range 30 {
 			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward))
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
-
-		l.MoveUp(1)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-	t.Run("should move at max to the top", func(t *testing.T) {
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Backward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
+		// should select the last item
+		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Len(t, l.indexMap, 30)
+		require.Len(t, l.items, 30)
+		require.Len(t, l.renderedItems, 30)
+		assert.Equal(t, 30, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 9, end)
+		for i := range 30 {
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
 		}
 
-		l.MoveUp(100)
-		assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should do nothing with wrong move number", func(t *testing.T) {
+	t.Run("should have correct positions in list that does not fits the items backwards", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 10 {
+		for i := range 30 {
 			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward))
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-		l.MoveUp(-10)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should move to the top", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Backward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
+		// should select the last item
+		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Len(t, l.indexMap, 30)
+		require.Len(t, l.items, 30)
+		require.Len(t, l.renderedItems, 30)
+		assert.Equal(t, 30, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 20, start)
+		assert.Equal(t, 29, end)
+		for i := range 30 {
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].start)
+			assert.Equal(t, i, l.renderedItems[items[i].ID()].end)
 		}
 
-		l.GoToTop()
-		assert.Equal(t, l.direction, Forward)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should select the item above", func(t *testing.T) {
+
+	t.Run("should have correct positions in list that does not fits the items and has multi line items", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-		selectedInx := len(l.items) - 2
-		currentItem := items[len(l.items)-1]
-		nextItem := items[selectedInx]
-		assert.False(t, nextItem.(SelectableItem).IsFocused())
-		assert.True(t, currentItem.(SelectableItem).IsFocused())
-		cmd = l.SelectItemAbove()
-		if cmd != nil {
-			cmd()
+		// should select the last item
+		assert.Equal(t, items[0].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Len(t, l.indexMap, 30)
+		require.Len(t, l.items, 30)
+		require.Len(t, l.renderedItems, 30)
+		expectedLines := 0
+		for i := range 30 {
+			expectedLines += (i + 1) * 1
+		}
+		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, 0, start)
+		assert.Equal(t, 9, end)
+		currentPosition := 0
+		for i := range 30 {
+			rItem := l.renderedItems[items[i].ID()]
+			assert.Equal(t, currentPosition, rItem.start)
+			assert.Equal(t, currentPosition+i, rItem.end)
+			currentPosition += i + 1
 		}
 
-		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
-		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
-
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
+	t.Run("should have correct positions in list that does not fits the items and has multi line items backwards", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-		for range 5 {
-			cmd = l.SelectItemAbove()
-			if cmd != nil {
-				cmd()
-			}
+		// should select the last item
+		assert.Equal(t, items[29].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
+		require.Len(t, l.indexMap, 30)
+		require.Len(t, l.items, 30)
+		require.Len(t, l.renderedItems, 30)
+		expectedLines := 0
+		for i := range 30 {
+			expectedLines += (i + 1) * 1
+		}
+		assert.Equal(t, expectedLines, lipgloss.Height(l.rendered))
+		assert.NotEqual(t, "\n", string(l.rendered[len(l.rendered)-1]), "should not end in newline")
+		start, end := l.viewPosition()
+		assert.Equal(t, expectedLines-10, start)
+		assert.Equal(t, expectedLines-1, end)
+		currentPosition := 0
+		for i := range 30 {
+			rItem := l.renderedItems[items[i].ID()]
+			assert.Equal(t, currentPosition, rItem.start)
+			assert.Equal(t, currentPosition+i, rItem.end)
+			currentPosition += i + 1
 		}
+
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-}
 
-func TestForwardList(t *testing.T) {
-	t.Parallel()
-	t.Run("within height", func(t *testing.T) {
+	t.Run("should go to selected item and center", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 5 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Forward), WithGap(1)).(*list[Item])
-		l.SetSize(10, 20)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
+		execCmd(l, l.Init())
 
 		// should select the last item
-		assert.Equal(t, l.selectedItem, items[0].ID())
+		assert.Equal(t, items[4].ID(), l.selectedItem)
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should not change selected item", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 5 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list[Item])
-		l.SetSize(10, 20)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
-		// should select the last item
-		assert.Equal(t, l.selectedItem, items[2].ID())
-	})
-	t.Run("more than height", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
 
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("more than height multi line", func(t *testing.T) {
+	t.Run("should go to selected item and center backwards", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[4].ID())).(*list[Item])
+		execCmd(l, l.Init())
 
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should move down", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		// should select the last item
+		assert.Equal(t, items[4].ID(), l.selectedItem)
 
-		l.MoveDown(1)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should move at max to the bottom", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
 
-		l.MoveDown(100)
-		assert.Equal(t, l.offset, lipgloss.Height(l.rendered)-l.listHeight())
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should do nothing with wrong move number", func(t *testing.T) {
+	t.Run("should go to selected item at the beginning", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
+		execCmd(l, l.Init())
 
-		l.MoveDown(-10)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should move to the bottom", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		// should select the last item
+		assert.Equal(t, items[10].ID(), l.selectedItem)
 
-		l.GoToBottom()
-		assert.Equal(t, l.direction, Backward)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should select the item below", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
 
-		selectedInx := 1
-		currentItem := items[0]
-		nextItem := items[selectedInx]
-		assert.False(t, nextItem.(SelectableItem).IsFocused())
-		assert.True(t, currentItem.(SelectableItem).IsFocused())
-		cmd = l.SelectItemBelow()
-		if cmd != nil {
-			cmd()
-		}
-
-		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
-		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
-
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should move the view to be able to see the selected item", func(t *testing.T) {
+	t.Run("should go to selected item at the beginning backwards", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 10 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(10, 5)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10), WithSelectedItem(items[10].ID())).(*list[Item])
+		execCmd(l, l.Init())
+
+		// should select the last item
+		assert.Equal(t, items[10].ID(), l.selectedItem)
 
-		for range 5 {
-			cmd = l.SelectItemBelow()
-			if cmd != nil {
-				cmd()
-			}
-		}
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 }
 
-func TestListSelection(t *testing.T) {
+func TestListMovement(t *testing.T) {
 	t.Parallel()
-	t.Run("should skip none selectable items initially", func(t *testing.T) {
+	t.Run("should move viewport up", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		items = append(items, NewSimpleItem("None Selectable"))
-		for i := range 5 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(100, 10)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-		assert.Equal(t, items[1].ID(), l.selectedItem)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should select the correct item on startup", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		for i := range 5 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		cmd := l.Init()
-		otherCmd := l.SetSelected(items[3].ID())
-		var wg sync.WaitGroup
-		if cmd != nil {
-			wg.Add(1)
-			go func() {
-				cmd()
-				wg.Done()
-			}()
-		}
-		if otherCmd != nil {
-			wg.Add(1)
-			go func() {
-				otherCmd()
-				wg.Done()
-			}()
-		}
-		wg.Wait()
-		l.SetSize(100, 10)
-		assert.Equal(t, items[3].ID(), l.selectedItem)
-		golden.RequireEqual(t, []byte(l.View()))
-	})
-	t.Run("should skip none selectable items in the middle", func(t *testing.T) {
-		t.Parallel()
-		items := []Item{}
-		item := NewSelectableItem("Item initial")
-		items = append(items, item)
-		items = append(items, NewSimpleItem("None Selectable"))
-		for i := range 5 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
-			items = append(items, item)
-		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(100, 10)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
-		l.SelectItemBelow()
-		assert.Equal(t, items[2].ID(), l.selectedItem)
+		execCmd(l, l.MoveUp(25))
+
+		assert.Equal(t, 25, l.offset)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-}
-
-func TestListSetSelection(t *testing.T) {
-	t.Parallel()
-	t.Run("should move to the selected item", func(t *testing.T) {
+	t.Run("should move viewport up and down", func(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
-		for i := range 100 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Forward)).(*list[Item])
-		l.SetSize(100, 10)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-		cmd = l.SetSelected(items[52].ID())
-		if cmd != nil {
-			cmd()
-		}
+		execCmd(l, l.MoveUp(25))
+		execCmd(l, l.MoveDown(25))
 
-		assert.Equal(t, items[52].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-}
 
-func TestListChanges(t *testing.T) {
-	t.Parallel()
-	t.Run("should append an item to the end", func(t *testing.T) {
+	t.Run("should move viewport down", func(t *testing.T) {
 		t.Parallel()
-		items := []SelectableItem{}
-		for i := range 20 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward)).(*list[SelectableItem])
-		l.SetSize(100, 10)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-		newItem := NewSelectableItem("New Item")
-		l.AppendItem(newItem)
+		execCmd(l, l.MoveDown(25))
 
-		assert.Equal(t, 21, len(l.items))
-		assert.Equal(t, 21, len(l.renderedItems))
-		assert.Equal(t, newItem.ID(), l.selectedItem)
+		assert.Equal(t, 25, l.offset)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should should not change the selected if we moved the offset", func(t *testing.T) {
+	t.Run("should move viewport down and up", func(t *testing.T) {
 		t.Parallel()
-		items := []SelectableItem{}
-		for i := range 20 {
-			item := NewSelectableItem(fmt.Sprintf("Item %d\nLine2", i))
+		items := []Item{}
+		for i := range 30 {
+			content := strings.Repeat(fmt.Sprintf("Item %d\n", i), i+1)
+			content = strings.TrimSuffix(content, "\n")
+			item := NewSelectableItem(content)
 			items = append(items, item)
 		}
-		l := New(items, WithDirection(Backward)).(*list[SelectableItem])
-		l.SetSize(100, 10)
-		cmd := l.Init()
-		if cmd != nil {
-			cmd()
-		}
-		l.MoveUp(1)
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
 
-		newItem := NewSelectableItem("New Item")
-		l.AppendItem(newItem)
+		execCmd(l, l.MoveDown(25))
+		execCmd(l, l.MoveUp(25))
 
-		assert.Equal(t, 21, len(l.items))
-		assert.Equal(t, 21, len(l.renderedItems))
-		assert.Equal(t, l.items[19].ID(), l.selectedItem)
+		assert.Equal(t, 0, l.offset)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
 }
@@ -711,3 +432,10 @@ func (s *selectableItem) Focus() tea.Cmd {
 func (s *selectableItem) IsFocused() bool {
 	return s.focused
 }
+
+func execCmd(m tea.Model, cmd tea.Cmd) {
+	for cmd != nil {
+		msg := cmd()
+		m, cmd = m.Update(msg)
+	}
+}