chore: fix viewport offset

Kujtim Hoxha created

Change summary

crush.json                                                                                                                                              |   9 
internal/tui/components/chat/chat.go                                                                                                                    |   8 
internal/tui/exp/list/list.go                                                                                                                           | 166 
internal/tui/exp/list/list_test.go                                                                                                                      | 237 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_bottom_in_backwards_list.golden  |  10 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_appended_and_we_are_at_the_botton.golden                    |  10 
internal/tui/exp/list/testdata/TestListMovement/should_not_change_offset_when_new_items_are_prepended_and_we_are_at_the_top_in_forward_list.golden      |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_appended_and_we_are_in_forward_list.golden              |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_if_an_item_is_prepended_and_we_are_in_backwards_list.golden           |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_down_in_forward_list.golden     |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up.golden                       |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_new_items_are_added_but_we_moved_up_in_backwards_list.golden     |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_decreases_in_forward_list.golden   |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased.golden                   |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_backwards_list.golden |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_above_is_increased_in_forward_list.golden   |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases.golden                   |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_decreases_in_backwards_list.golden |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased#01.golden                |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased.golden                   |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_backwards_list.golden |  10 
internal/tui/exp/list/testdata/TestListMovement/should_stay_at_the_position_it_is_when_the_hight_of_an_item_below_is_increased_in_forward_list.golden   |  10 
internal/tui/page/chat/chat.go                                                                                                                          |   1 
23 files changed, 591 insertions(+), 10 deletions(-)

Detailed changes

crush.json ๐Ÿ”—

@@ -3,5 +3,14 @@
     "Go": {
       "command": "gopls"
     }
+  },
+  "mcp": {
+    "linear": {
+      "type": "stdio",
+      "command": "mcp-remote",
+      "args": [
+        "https://mcp.linear.app/sse"
+      ]
+    }
   }
 }

internal/tui/components/chat/chat.go ๐Ÿ”—

@@ -40,6 +40,7 @@ type MessageListCmp interface {
 	layout.Help
 
 	SetSession(session.Session) tea.Cmd
+	GoToBottom() tea.Cmd
 }
 
 // messageListCmp implements MessageListCmp, providing a virtualized list
@@ -64,6 +65,7 @@ func New(app *app.App) MessageListCmp {
 		[]list.Item{},
 		list.WithGap(1),
 		list.WithDirectionBackward(),
+		list.WithFocus(false),
 		list.WithKeyMap(defaultListKeyMap),
 	)
 	return &messageListCmp{
@@ -76,7 +78,7 @@ func New(app *app.App) MessageListCmp {
 
 // Init initializes the component.
 func (m *messageListCmp) Init() tea.Cmd {
-	return tea.Sequence(m.listCmp.Init(), m.listCmp.Blur())
+	return m.listCmp.Init()
 }
 
 // Update handles incoming messages and updates the component state.
@@ -531,3 +533,7 @@ func (m *messageListCmp) IsFocused() bool {
 func (m *messageListCmp) Bindings() []key.Binding {
 	return m.defaultListKeyMap.KeyBindings()
 }
+
+func (m *messageListCmp) GoToBottom() tea.Cmd {
+	return m.listCmp.GoToBottom()
+}

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

@@ -1,10 +1,12 @@
 package list
 
 import (
+	"slices"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
 	"github.com/charmbracelet/crush/internal/tui/util"
 	"github.com/charmbracelet/lipgloss/v2"
@@ -16,6 +18,10 @@ type Item interface {
 	ID() string
 }
 
+type HasAnim interface {
+	Item
+	Spinning() bool
+}
 type (
 	renderedMsg  struct{}
 	List[T Item] interface {
@@ -172,6 +178,18 @@ func (l *list[T]) Init() tea.Cmd {
 // Update implements List.
 func (l *list[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
+	case anim.StepMsg:
+		var cmds []tea.Cmd
+		for _, item := range l.items {
+			if i, ok := any(item).(HasAnim); ok && i.Spinning() {
+				updated, cmd := i.Update(msg)
+				cmds = append(cmds, cmd)
+				if u, ok := updated.(T); ok {
+					cmds = append(cmds, l.UpdateItem(u.ID(), u))
+				}
+			}
+		}
+		return l, tea.Batch(cmds...)
 	case tea.KeyPressMsg:
 		if l.focused {
 			switch {
@@ -553,8 +571,44 @@ func (l *list[T]) renderItem(item Item) renderedItem {
 }
 
 // AppendItem implements List.
-func (l *list[T]) AppendItem(T) tea.Cmd {
-	panic("unimplemented")
+func (l *list[T]) AppendItem(item T) tea.Cmd {
+	var cmds []tea.Cmd
+	cmd := item.Init()
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	l.items = append(l.items, item)
+	l.indexMap = make(map[string]int)
+	for inx, item := range l.items {
+		l.indexMap[item.ID()] = inx
+	}
+	if l.width > 0 && l.height > 0 {
+		cmd = item.SetSize(l.width, l.height)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	cmd = l.render()
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	if l.direction == DirectionBackward {
+		if l.offset == 0 {
+			cmd = l.GoToBottom()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		} else {
+			newItem := l.renderedItems[item.ID()]
+			newLines := newItem.height
+			if len(l.items) > 1 {
+				newLines += l.gap
+			}
+			l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+		}
+	}
+	return tea.Sequence(cmds...)
 }
 
 // Blur implements List.
@@ -564,8 +618,34 @@ func (l *list[T]) Blur() tea.Cmd {
 }
 
 // DeleteItem implements List.
-func (l *list[T]) DeleteItem(string) tea.Cmd {
-	panic("unimplemented")
+func (l *list[T]) DeleteItem(id string) tea.Cmd {
+	inx := l.indexMap[id]
+	l.items = slices.Delete(l.items, inx, inx+1)
+	delete(l.renderedItems, id)
+	for inx, item := range l.items {
+		l.indexMap[item.ID()] = inx
+	}
+
+	if l.selectedItem == id {
+		if inx > 0 {
+			l.selectedItem = l.items[inx-1].ID()
+		} else {
+			l.selectedItem = ""
+		}
+	}
+	cmd := l.render()
+	if l.rendered != "" {
+		renderedHeight := lipgloss.Height(l.rendered)
+		if renderedHeight <= l.height {
+			l.offset = 0
+		} else {
+			maxOffset := renderedHeight - l.height
+			if l.offset > maxOffset {
+				l.offset = maxOffset
+			}
+		}
+	}
+	return cmd
 }
 
 // Focus implements List.
@@ -651,8 +731,35 @@ func (l *list[T]) MoveUp(n int) tea.Cmd {
 }
 
 // PrependItem implements List.
-func (l *list[T]) PrependItem(T) tea.Cmd {
-	panic("unimplemented")
+func (l *list[T]) PrependItem(item T) tea.Cmd {
+	cmds := []tea.Cmd{
+		item.Init(),
+	}
+	l.items = append([]T{item}, l.items...)
+	l.indexMap = make(map[string]int)
+	for inx, item := range l.items {
+		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())
+	if l.direction == DirectionForward {
+		if l.offset == 0 {
+			cmd := l.GoToTop()
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		} else {
+			newItem := l.renderedItems[item.ID()]
+			newLines := newItem.height
+			if len(l.items) > 1 {
+				newLines += l.gap
+			}
+			l.offset = min(lipgloss.Height(l.rendered)-1, l.offset+newLines)
+		}
+	}
+	return tea.Batch(cmds...)
 }
 
 // SelectItemAbove implements List.
@@ -707,7 +814,12 @@ func (l *list[T]) SelectedItem() *T {
 // SetItems implements List.
 func (l *list[T]) SetItems(items []T) tea.Cmd {
 	l.items = items
-	return l.reset()
+	var cmds []tea.Cmd
+	for _, item := range l.items {
+		cmds = append(cmds, item.Init())
+	}
+	cmds = append(cmds, l.reset())
+	return tea.Batch(cmds...)
 }
 
 // SetSelected implements List.
@@ -745,6 +857,42 @@ func (l *list[T]) SetSize(width int, height int) tea.Cmd {
 }
 
 // UpdateItem implements List.
-func (l *list[T]) UpdateItem(string, T) tea.Cmd {
-	panic("unimplemented")
+func (l *list[T]) UpdateItem(id string, item T) tea.Cmd {
+	var cmds []tea.Cmd
+	if inx, ok := l.indexMap[id]; ok {
+		l.items[inx] = item
+		oldItem := l.renderedItems[id]
+		oldPosition := l.offset
+		if l.direction == DirectionBackward {
+			oldPosition = (lipgloss.Height(l.rendered) - 1) - l.offset
+		}
+
+		delete(l.renderedItems, id)
+		cmd := l.render()
+
+		// need to check for nil because of sequence not handling nil
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		if l.direction == DirectionBackward {
+			// if we are the last item and there is no offset
+			// make sure to go to the bottom
+			if inx == len(l.items)-1 && l.offset == 0 {
+				cmd = l.GoToBottom()
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+				// if the item is at least partially below the viewport
+			} else if oldPosition < oldItem.end {
+				newItem := l.renderedItems[item.ID()]
+				newLines := newItem.height - oldItem.height
+				l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+			}
+		} else if l.offset > oldItem.start {
+			newItem := l.renderedItems[item.ID()]
+			newLines := newItem.height - oldItem.height
+			l.offset = util.Clamp(l.offset+newLines, 0, lipgloss.Height(l.rendered)-1)
+		}
+	}
+	return tea.Sequence(cmds...)
 }

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

@@ -315,6 +315,243 @@ func TestListMovement(t *testing.T) {
 		assert.Equal(t, 0, l.offset)
 		golden.RequireEqual(t, []byte(l.View()))
 	})
+
+	t.Run("should not change offset when new items are appended and we are at the bottom in backwards list", func(t *testing.T) {
+		t.Parallel()
+		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, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+		execCmd(l, l.AppendItem(NewSelectableItem("Testing")))
+
+		assert.Equal(t, 0, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when new items are added but we moved up in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		execCmd(l, l.AppendItem(NewSelectableItem("Testing\nHello\n")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 5, l.offset)
+		assert.Equal(t, 33, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is when the hight of an item below is increased in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		item := items[29]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 4, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is when the hight of an item below is decreases in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		items = append(items, NewSelectableItem("Item 30\nLine 2\nLine 3"))
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		item := items[30]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 30")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 0, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is when the hight of an item above is increased in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		item := items[1]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 1\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is if an item is prepended and we are in backwards list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionBackward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveUp(2))
+		viewBefore := l.View()
+		execCmd(l, l.PrependItem(NewSelectableItem("New")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should not change offset when new items are prepended and we are at the top in forward list", func(t *testing.T) {
+		t.Parallel()
+		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, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+		execCmd(l, l.PrependItem(NewSelectableItem("Testing")))
+
+		assert.Equal(t, 0, l.offset)
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when new items are added but we moved down in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		execCmd(l, l.PrependItem(NewSelectableItem("Testing\nHello\n")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 5, l.offset)
+		assert.Equal(t, 33, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when the hight of an item above is increased in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		item := items[0]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 4, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when the hight of an item above is decreases in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		items = append(items, NewSelectableItem("At top\nLine 2\nLine 3"))
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(3))
+		viewBefore := l.View()
+		item := items[0]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("At top")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 1, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+
+	t.Run("should stay at the position it is when the hight of an item below is increased in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		item := items[29]
+		execCmd(l, l.UpdateItem(item.ID(), NewSelectableItem("Item 29\nLine 2\nLine 3")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 32, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
+	t.Run("should stay at the position it is if an item is appended and we are in forward list", func(t *testing.T) {
+		t.Parallel()
+		items := []Item{}
+		for i := range 30 {
+			item := NewSelectableItem(fmt.Sprintf("Item %d", i))
+			items = append(items, item)
+		}
+		l := New(items, WithDirectionForward(), WithSize(10, 10)).(*list[Item])
+		execCmd(l, l.Init())
+
+		execCmd(l, l.MoveDown(2))
+		viewBefore := l.View()
+		execCmd(l, l.AppendItem(NewSelectableItem("New")))
+		viewAfter := l.View()
+		assert.Equal(t, viewBefore, viewAfter)
+		assert.Equal(t, 2, l.offset)
+		assert.Equal(t, 31, lipgloss.Height(l.rendered))
+		golden.RequireEqual(t, []byte(l.View()))
+	})
 }
 
 type SelectableItem interface {

internal/tui/page/chat/chat.go ๐Ÿ”—

@@ -604,6 +604,7 @@ func (p *chatPage) sendMessage(text string, attachments []message.Attachment) te
 	if err != nil {
 		return util.ReportError(err)
 	}
+	cmds = append(cmds, p.chat.GoToBottom())
 	return tea.Batch(cmds...)
 }