chore: more tests

Kujtim Hoxha created

Change summary

internal/tui/exp/list/list.go                                                                       |  82 
internal/tui/exp/list/list_test.go                                                                  | 197 
internal/tui/exp/list/testdata/TestBackwardList/should_move_at_max_to_the_top.golden                |   2 
internal/tui/exp/list/testdata/TestBackwardList/should_move_up.golden                               |   2 
internal/tui/exp/list/testdata/TestBackwardList/within_height.golden                                |   2 
internal/tui/exp/list/testdata/TestForwardList/should_move_at_max_to_the_bottom.golden              |   2 
internal/tui/exp/list/testdata/TestForwardList/should_move_down.golden                              |   2 
internal/tui/exp/list/testdata/TestForwardList/within_height.golden                                 |   2 
internal/tui/exp/list/testdata/TestListSelection/should_skip_none_selectable_items_initially.golden |   6 
9 files changed, 239 insertions(+), 58 deletions(-)

Detailed changes

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

@@ -1,6 +1,7 @@
 package list
 
 import (
+	"fmt"
 	"strings"
 
 	tea "github.com/charmbracelet/bubbletea/v2"
@@ -132,19 +133,20 @@ func (l *list) View() string {
 	view := l.rendered
 	lines := strings.Split(view, "\n")
 
-	start, end := l.viewPosition(len(lines))
-	lines = lines[start:end]
+	start, end := l.viewPosition()
+	lines = lines[start : end+1]
 	return strings.Join(lines, "\n")
 }
 
-func (l *list) viewPosition(total int) (int, int) {
+func (l *list) viewPosition() (int, int) {
 	start, end := 0, 0
+	renderedLines := lipgloss.Height(l.rendered) - 1
 	if l.direction == Forward {
 		start = max(0, l.offset)
-		end = min(l.offset+l.listHeight(), total)
+		end = min(l.offset+l.listHeight()-1, renderedLines)
 	} else {
-		start = max(0, total-l.offset-l.listHeight())
-		end = max(0, total-l.offset)
+		start = max(0, renderedLines-l.offset-l.listHeight()+1)
+		end = max(0, renderedLines-l.offset)
 	}
 	return start, end
 }
@@ -200,20 +202,71 @@ func (l *list) decrementOffset(n int) {
 	}
 }
 
-func (l *list) MoveUp(n int) {
+// changeSelectedWhenNotVisible is called so we make sure we move to the next available selected that is visible
+func (l *list) changeSelectedWhenNotVisible() tea.Cmd {
+	var cmds []tea.Cmd
+	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 is completely below the viewport
+			if itemStart > end && itemEnd > end {
+				needsMove = true
+			}
+			if needsMove {
+				if focusable, ok := item.(layout.Focusable); ok {
+					cmds = append(cmds, focusable.Blur())
+				}
+				l.renderedItems[i] = l.renderItem(item)
+			} else {
+				return nil
+			}
+		}
+		if itemWithinView != NotFound && needsMove {
+			newSelection := l.items[itemWithinView]
+			l.selectedItem = newSelection.ID()
+			if focusable, ok := newSelection.(layout.Focusable); ok {
+				cmds = append(cmds, focusable.Focus())
+			}
+			l.renderedItems[itemWithinView] = l.renderItem(newSelection)
+			break
+		}
+		currentPosition += rendered.height + l.gap
+	}
+	l.renderView()
+	return tea.Batch(cmds...)
+}
+
+func (l *list) MoveUp(n int) tea.Cmd {
 	if l.direction == Forward {
 		l.decrementOffset(n)
 	} else {
 		l.incrementOffset(n)
 	}
+	return l.changeSelectedWhenNotVisible()
 }
 
-func (l *list) MoveDown(n int) {
+func (l *list) MoveDown(n int) tea.Cmd {
 	if l.direction == Forward {
 		l.incrementOffset(n)
 	} else {
 		l.decrementOffset(n)
 	}
+	return l.changeSelectedWhenNotVisible()
 }
 
 func (l *list) firstSelectableItemBefore(inx int) int {
@@ -239,10 +292,10 @@ func (l *list) moveToSelected() {
 		return
 	}
 	currentPosition := 0
-	start, end := l.viewPosition(lipgloss.Height(l.rendered))
+	start, end := l.viewPosition()
 	for _, item := range l.renderedItems {
 		if item.id == l.selectedItem {
-			if start <= currentPosition && currentPosition <= end {
+			if start <= currentPosition && (currentPosition+item.height) <= end {
 				return
 			}
 			// we need to go up
@@ -354,7 +407,7 @@ func (l *list) renderForward() tea.Cmd {
 	currentIndex := 0
 	for i, item := range l.items {
 		currentIndex = i
-		if currentHeight > l.listHeight() {
+		if currentHeight-1 > l.listHeight() {
 			break
 		}
 		rendered := l.renderItem(item)
@@ -387,6 +440,7 @@ func (l *list) renderBackward() tea.Cmd {
 	currentHeight := 0
 	currentIndex := 0
 	for i := len(l.items) - 1; i >= 0; i-- {
+		fmt.Printf("rendering item %d\n", i)
 		currentIndex = i
 		if currentHeight > l.listHeight() {
 			break
@@ -397,12 +451,13 @@ func (l *list) renderBackward() tea.Cmd {
 	}
 	// initial render
 	l.renderView()
-	if currentIndex == len(l.items)-1 {
+	if currentIndex == 0 {
 		l.isReady = true
 		return nil
 	}
 	return func() tea.Msg {
 		for i := currentIndex; i >= 0; i-- {
+			fmt.Printf("rendering item after %d\n", i)
 			rendered := l.renderItem(l.items[i])
 			l.renderedItems = append([]renderedItem{rendered}, l.renderedItems...)
 		}
@@ -451,6 +506,9 @@ func (l *list) renderItems() tea.Cmd {
 			l.selectLastItem()
 		}
 	}
+	if l.direction == Forward {
+		return l.renderForward()
+	}
 	return l.renderBackward()
 }
 

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

@@ -12,6 +12,93 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestListPosition(t *testing.T) {
+	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) {
+			l := New(WithDirection(c.dir)).(*list)
+			l.SetSize(c.width, c.height)
+			items := []Item{}
+			for i := range c.numItems {
+				item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+				items = append(items, item)
+			}
+			cmd := l.SetItems(items)
+			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.Run("within height", func(t *testing.T) {
 		t.Parallel()
@@ -19,7 +106,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 20)
 		items := []Item{}
 		for i := range 5 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -36,7 +123,7 @@ func TestBackwardList(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
 		for i := range 5 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		l := New(WithDirection(Backward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
@@ -54,7 +141,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -70,7 +157,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -86,7 +173,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -97,13 +184,13 @@ func TestBackwardList(t *testing.T) {
 		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))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -121,7 +208,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -138,7 +225,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -156,7 +243,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -167,15 +254,15 @@ func TestBackwardList(t *testing.T) {
 		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())
+		assert.False(t, nextItem.(SelectableItem).IsFocused())
+		assert.True(t, currentItem.(SelectableItem).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())
+		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})
@@ -185,7 +272,7 @@ func TestBackwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -210,7 +297,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 20)
 		items := []Item{}
 		for i := range 5 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -227,7 +314,7 @@ func TestForwardList(t *testing.T) {
 		t.Parallel()
 		items := []Item{}
 		for i := range 5 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		l := New(WithDirection(Forward), WithGap(1), WithSelectedItem(items[2].ID())).(*list)
@@ -245,7 +332,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -261,7 +348,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d\nLine2", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d\nLine2", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -277,7 +364,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -288,13 +375,13 @@ func TestForwardList(t *testing.T) {
 		l.MoveDown(1)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
-	t.Run("should move at max to the top", func(t *testing.T) {
+	t.Run("should move at max 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))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -312,7 +399,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -329,7 +416,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -347,7 +434,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -358,15 +445,15 @@ func TestForwardList(t *testing.T) {
 		selectedInx := 1
 		currentItem := items[0]
 		nextItem := items[selectedInx]
-		assert.False(t, nextItem.(SimpleItem).IsFocused())
-		assert.True(t, currentItem.(SimpleItem).IsFocused())
+		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].(SimpleItem).IsFocused())
+		assert.True(t, l.items[selectedInx].(SelectableItem).IsFocused())
 
 		golden.RequireEqual(t, []byte(l.View()))
 	})
@@ -376,7 +463,7 @@ func TestForwardList(t *testing.T) {
 		l.SetSize(10, 5)
 		items := []Item{}
 		for i := range 10 {
-			item := NewSimpleItem(fmt.Sprintf("Item %d", i))
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
 			items = append(items, item)
 		}
 		cmd := l.SetItems(items)
@@ -394,7 +481,28 @@ func TestForwardList(t *testing.T) {
 	})
 }
 
-type SimpleItem interface {
+func TestListSelection(t *testing.T) {
+	t.Run("should skip none selectable items initially", func(t *testing.T) {
+		t.Parallel()
+		l := New(WithDirection(Forward)).(*list)
+		l.SetSize(100, 10)
+		items := []Item{}
+		items = append(items, NewSimpleItem("None Selectable"))
+		for i := range 5 {
+			item := NewSelectsableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		cmd := l.SetItems(items)
+		if cmd != nil {
+			cmd()
+		}
+
+		assert.Equal(t, items[1].ID(), l.selectedItem)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+}
+
+type SelectableItem interface {
 	Item
 	layout.Focusable
 }
@@ -403,15 +511,24 @@ type simpleItem struct {
 	width   int
 	content string
 	id      string
+}
+type selectableItem struct {
+	*simpleItem
 	focused bool
 }
 
-func NewSimpleItem(content string) SimpleItem {
+func NewSimpleItem(content string) *simpleItem {
 	return &simpleItem{
+		id:      uuid.NewString(),
 		width:   0,
 		content: content,
-		focused: false,
-		id:      uuid.NewString(),
+	}
+}
+
+func NewSelectsableItem(content string) SelectableItem {
+	return &selectableItem{
+		simpleItem: NewSimpleItem(content),
+		focused:    false,
 	}
 }
 
@@ -428,9 +545,6 @@ func (s *simpleItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 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)
 }
 
@@ -444,19 +558,26 @@ func (s *simpleItem) SetSize(width int, height int) tea.Cmd {
 	return nil
 }
 
+func (s *selectableItem) 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)
+}
+
 // Blur implements SimpleItem.
-func (s *simpleItem) Blur() tea.Cmd {
+func (s *selectableItem) Blur() tea.Cmd {
 	s.focused = false
 	return nil
 }
 
 // Focus implements SimpleItem.
-func (s *simpleItem) Focus() tea.Cmd {
+func (s *selectableItem) Focus() tea.Cmd {
 	s.focused = true
 	return nil
 }
 
 // IsFocused implements SimpleItem.
-func (s *simpleItem) IsFocused() bool {
+func (s *selectableItem) IsFocused() bool {
 	return s.focused
 }