chore: initial implementation

Kujtim Hoxha created

Change summary

internal/tui/exp/list/list.go                                                                                   | 475 
internal/tui/exp/list/list_test.go                                                                              | 462 
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                                            |  11 
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_top.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                                             |  11 
20 files changed, 1,022 insertions(+), 17 deletions(-)

Detailed changes

internal/tui/exp/list/list.go ๐Ÿ”—

@@ -1,41 +1,60 @@
 package list
 
 import (
+	"strings"
+
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/lipgloss/v2"
 )
 
 type Item interface {
 	util.Model
 	layout.Sizeable
+	ID() string
 }
 
 type List interface {
 	util.Model
+	layout.Sizeable
+	layout.Focusable
+	SetItems(items []Item) tea.Cmd
+}
+
+type direction int
+
+const (
+	Forward direction = iota
+	Backward
+)
+
+const (
+	NotFound = -1
+)
+
+type renderedItem struct {
+	id     string
+	view   string
+	height int
 }
 
 type list struct {
 	width, height int
+	offset        int
 	gap           int
+	direction     direction
+	selectedItem  string
+	focused       bool
 
-	items []Item
-
-	// Filter options
-	filterable        bool
-	filterPlaceholder string
+	items         []Item
+	renderedItems []renderedItem
+	rendered      string
+	isReady       bool
 }
 
 type listOption func(*list)
 
-// WithFilterable enables filtering on the list.
-func WithFilterable(placeholder string) listOption {
-	return func(l *list) {
-		l.filterable = true
-		l.filterPlaceholder = placeholder
-	}
-}
-
 // WithItems sets the initial items for the list.
 func WithItems(items ...Item) listOption {
 	return func(l *list) {
@@ -58,9 +77,24 @@ func WithGap(gap int) listOption {
 	}
 }
 
+// WithDirection sets the direction of the list.
+func WithDirection(dir direction) listOption {
+	return func(l *list) {
+		l.direction = dir
+	}
+}
+
+// WithSelectedItem sets the initially selected item in the list.
+func WithSelectedItem(id string) listOption {
+	return func(l *list) {
+		l.selectedItem = id
+	}
+}
+
 func New(opts ...listOption) List {
 	list := &list{
-		items: make([]Item, 0),
+		items:     make([]Item, 0),
+		direction: Forward,
 	}
 	for _, opt := range opts {
 		opt(list)
@@ -73,15 +107,422 @@ func (l *list) Init() tea.Cmd {
 	if l.height <= 0 || l.width <= 0 {
 		return nil
 	}
-	return nil
+	if len(l.items) == 0 {
+		return nil
+	}
+	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...)
 }
 
 // Update implements List.
 func (l *list) Update(tea.Msg) (tea.Model, tea.Cmd) {
-	panic("unimplemented")
+	return l, nil
 }
 
 // View implements List.
 func (l *list) View() string {
-	panic("unimplemented")
+	if l.height <= 0 || l.width <= 0 {
+		return ""
+	}
+	view := l.rendered
+	lines := strings.Split(view, "\n")
+
+	start, end := l.viewPosition(len(lines))
+	lines = lines[start:end]
+	return strings.Join(lines, "\n")
+}
+
+func (l *list) viewPosition(total int) (int, int) {
+	start, end := 0, 0
+	if l.direction == Forward {
+		start = max(0, l.offset)
+		end = min(l.offset+l.listHeight(), total)
+	} else {
+		start = max(0, total-l.offset-l.listHeight())
+		end = max(0, total-l.offset)
+	}
+	return start, end
+}
+
+func (l *list) renderItem(item Item) renderedItem {
+	view := item.View()
+	return renderedItem{
+		id:     item.ID(),
+		view:   view,
+		height: lipgloss.Height(view),
+	}
+}
+
+func (l *list) 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))
+		}
+	}
+	l.rendered = sb.String()
+}
+
+func (l *list) incrementOffset(n int) {
+	if !l.isReady {
+		return
+	}
+	renderedHeight := lipgloss.Height(l.rendered)
+	// no need for offset
+	if renderedHeight <= l.listHeight() {
+		return
+	}
+	maxOffset := renderedHeight - l.listHeight()
+	n = min(n, maxOffset-l.offset)
+	if n <= 0 {
+		return
+	}
+	l.offset += n
+}
+
+func (l *list) decrementOffset(n int) {
+	if !l.isReady {
+		return
+	}
+	n = min(n, l.offset)
+	if n <= 0 {
+		return
+	}
+	l.offset -= n
+	if l.offset < 0 {
+		l.offset = 0
+	}
+}
+
+func (l *list) MoveUp(n int) {
+	if l.direction == Forward {
+		l.decrementOffset(n)
+	} else {
+		l.incrementOffset(n)
+	}
+}
+
+func (l *list) MoveDown(n int) {
+	if l.direction == Forward {
+		l.incrementOffset(n)
+	} else {
+		l.decrementOffset(n)
+	}
+}
+
+func (l *list) firstSelectableItemBefore(inx int) int {
+	for i := inx - 1; i >= 0; i-- {
+		if _, ok := l.items[i].(layout.Focusable); ok {
+			return i
+		}
+	}
+	return NotFound
+}
+
+func (l *list) firstSelectableItemAfter(inx int) int {
+	for i := inx + 1; i < len(l.items); i++ {
+		if _, ok := l.items[i].(layout.Focusable); ok {
+			return i
+		}
+	}
+	return NotFound
+}
+
+func (l *list) moveToSelected() {
+	if l.selectedItem == "" || !l.isReady {
+		return
+	}
+	currentPosition := 0
+	start, end := l.viewPosition(lipgloss.Height(l.rendered))
+	for _, item := range l.renderedItems {
+		if item.id == l.selectedItem {
+			if start <= currentPosition && currentPosition <= end {
+				return
+			}
+			// we need to go up
+			if currentPosition < start {
+				l.MoveUp(start - currentPosition)
+			}
+			// we need to go down
+			if currentPosition > end {
+				l.MoveDown(currentPosition - end)
+			}
+		}
+		currentPosition += item.height + l.gap
+	}
+}
+
+func (l *list) SelectItemAbove() tea.Cmd {
+	if !l.isReady {
+		return nil
+	}
+	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 := 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 := above.(layout.Focusable); ok {
+				cmds = append(cmds, focusable.Focus())
+			}
+			// rerender the item
+			l.renderedItems[inx] = l.renderItem(above)
+			l.selectedItem = above.ID()
+			break
+		}
+	}
+	l.renderView()
+	l.moveToSelected()
+	return tea.Batch(cmds...)
+}
+
+func (l *list) 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 := item.(layout.Focusable); ok {
+				cmds = append(cmds, focusable.Blur())
+			}
+			// rerender the item
+			l.renderedItems[i] = l.renderItem(item)
+
+			// focus the item below
+			below := l.items[inx]
+			if focusable, ok := below.(layout.Focusable); ok {
+				cmds = append(cmds, focusable.Focus())
+			}
+			// rerender the item
+			l.renderedItems[inx] = l.renderItem(below)
+			l.selectedItem = below.ID()
+			break
+		}
+	}
+
+	l.renderView()
+	l.moveToSelected()
+	return tea.Batch(cmds...)
+}
+
+func (l *list) GoToTop() tea.Cmd {
+	if !l.isReady {
+		return nil
+	}
+	l.offset = 0
+	l.direction = Forward
+	return tea.Batch(l.selectFirstItem(), l.renderForward())
+}
+
+func (l *list) GoToBottom() tea.Cmd {
+	if !l.isReady {
+		return nil
+	}
+	l.offset = 0
+	l.direction = Backward
+
+	return tea.Batch(l.selectLastItem(), l.renderBackward())
+}
+
+func (l *list) 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 > l.listHeight() {
+			break
+		}
+		rendered := l.renderItem(item)
+		l.renderedItems = append(l.renderedItems, rendered)
+		currentHeight += rendered.height + l.gap
+	}
+
+	// initial render
+	l.renderView()
+
+	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
+	}
+}
+
+func (l *list) 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 == len(l.items)-1 {
+		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
+	}
+}
+
+func (l *list) selectFirstItem() tea.Cmd {
+	var cmd tea.Cmd
+	inx := l.firstSelectableItemAfter(-1)
+	if inx != NotFound {
+		l.selectedItem = l.items[inx].ID()
+		if focusable, ok := l.items[inx].(layout.Focusable); ok {
+			cmd = focusable.Focus()
+		}
+	}
+	return cmd
+}
+
+func (l *list) 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 := l.items[inx].(layout.Focusable); ok {
+			cmd = focusable.Focus()
+		}
+	}
+	return cmd
+}
+
+func (l *list) renderItems() tea.Cmd {
+	if l.height <= 0 || l.width <= 0 {
+		return nil
+	}
+	if len(l.items) == 0 {
+		return nil
+	}
+
+	if l.selectedItem == "" {
+		if l.direction == Forward {
+			l.selectFirstItem()
+		} else {
+			l.selectLastItem()
+		}
+	}
+	return l.renderBackward()
+}
+
+func (l *list) listHeight() int {
+	// for the moment its the same
+	return l.height
+}
+
+func (l *list) SetItems(items []Item) 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))
+	}
+	cmds = append(cmds, l.renderItems())
+	return tea.Batch(cmds...)
+}
+
+// GetSize implements List.
+func (l *list) GetSize() (int, int) {
+	return l.width, l.height
+}
+
+// SetSize implements List.
+func (l *list) 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))
+	}
+	cmds = append(cmds, l.renderItems())
+	return tea.Batch(cmds...)
+}
+
+// Blur implements List.
+func (l *list) 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 := item.(layout.Focusable); ok {
+			cmd = focusable.Blur()
+		}
+		l.renderedItems[i] = l.renderItem(item)
+	}
+	l.renderView()
+	return cmd
+}
+
+// Focus implements List.
+func (l *list) Focus() tea.Cmd {
+	var cmd tea.Cmd
+	l.focused = true
+	for i, item := range l.items {
+		if item.ID() != l.selectedItem {
+			continue
+		}
+		if focusable, ok := item.(layout.Focusable); ok {
+			cmd = focusable.Focus()
+		}
+		l.renderedItems[i] = l.renderItem(item)
+	}
+	l.renderView()
+	return cmd
+}
+
+// IsFocused implements List.
+func (l *list) IsFocused() bool {
+	return l.focused
 }

internal/tui/exp/list/list_test.go ๐Ÿ”—

@@ -0,0 +1,462 @@
+package list
+
+import (
+	"fmt"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/exp/golden"
+	"github.com/google/uuid"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestBackwardList(t *testing.T) {
+	t.Run("within height", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Backward), WithGap(1)).(*list)
+		l.SetSize(10, 20)
+		items := []Item{}
+		for i := range 5 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		// should select the last item
+		assert.Equal(t, l.selectedItem, items[len(items)-1].ID())
+
+		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 := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
+		l.SetSize(10, 20)
+		cmd := l.SetItems(items)
+		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()
+		l := New(WithDirection(Backward))
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("more than height multi line", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Backward))
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move up", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Backward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		l.MoveUp(1)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move at max to the top", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Backward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		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.Parallel()
+		l := New(WithDirection(Backward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		l.MoveUp(-10)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move to the top", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Backward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		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.Parallel()
+		l := New(WithDirection(Backward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		selectedInx := len(l.items) - 2
+		currentItem := items[len(l.items)-1]
+		nextItem := items[selectedInx]
+		assert.False(t, nextItem.(SimpleItem).IsFocused())
+		assert.True(t, currentItem.(SimpleItem).IsFocused())
+		cmd = l.SelectItemAbove()
+		if cmd != nil {
+			cmd()
+		}
+
+		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
+		assert.True(t, l.items[selectedInx].(SimpleItem).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.Parallel()
+		l := New(WithDirection(Backward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		for range 5 {
+			cmd = l.SelectItemAbove()
+			if cmd != nil {
+				cmd()
+			}
+		}
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
+func TestForwardList(t *testing.T) {
+	t.Run("within height", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Forward), WithGap(1)).(*list)
+		l.SetSize(10, 20)
+		items := []Item{}
+		for i := range 5 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		// should select the last item
+		assert.Equal(t, l.selectedItem, items[0].ID())
+
+		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 := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
+		l.SetSize(10, 20)
+		cmd := l.SetItems(items)
+		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()
+		l := New(WithDirection(Forward))
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("more than height multi line", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Forward))
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move down", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Forward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		l.MoveDown(1)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move at max to the top", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Forward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		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.Parallel()
+		l := New(WithDirection(Forward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		l.MoveDown(-10)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should move to the bottom", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Forward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		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()
+		l := New(WithDirection(Forward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		selectedInx := 1
+		currentItem := items[0]
+		nextItem := items[selectedInx]
+		assert.False(t, nextItem.(SimpleItem).IsFocused())
+		assert.True(t, currentItem.(SimpleItem).IsFocused())
+		cmd = l.SelectItemBelow()
+		if cmd != nil {
+			cmd()
+		}
+
+		assert.Equal(t, l.selectedItem, l.items[selectedInx].ID())
+		assert.True(t, l.items[selectedInx].(SimpleItem).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.Parallel()
+		l := New(WithDirection(Backward)).(*list)
+		l.SetSize(10, 5)
+		items := []Item{}
+		for i := range 10 {
+			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		for range 5 {
+			cmd = l.SelectItemBelow()
+			if cmd != nil {
+				cmd()
+			}
+		}
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
+type SimpleItem interface {
+	Item
+	layout.Focusable
+}
+
+type simpleItem struct {
+	width   int
+	content string
+	id      string
+	focused bool
+}
+
+func NewSimpleItem(content string) SimpleItem {
+	return &simpleItem{
+		width:   0,
+		content: content,
+		focused: false,
+		id:      uuid.NewString(),
+	}
+}
+
+func (s *simpleItem) ID() string {
+	return s.id
+}
+
+func (s *simpleItem) Init() tea.Cmd {
+	return nil
+}
+
+func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return s, nil
+}
+
+func (s *simpleItem) View() string {
+	if s.focused {
+		return lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Width(s.width).Render(s.content)
+	}
+	return lipgloss.NewStyle().Width(s.width).Render(s.content)
+}
+
+func (l *simpleItem) GetSize() (int, int) {
+	return l.width, 0
+}
+
+// SetSize implements Item.
+func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
+	s.width = width
+	return nil
+}
+
+// Blur implements SimpleItem.
+func (s *simpleItem) Blur() tea.Cmd {
+	s.focused = false
+	return nil
+}
+
+// Focus implements SimpleItem.
+func (s *simpleItem) Focus() tea.Cmd {
+	s.focused = true
+	return nil
+}
+
+// IsFocused implements SimpleItem.
+func (s *simpleItem) IsFocused() bool {
+	return s.focused
+}