refactor(chat): only show animations for items that are visible

Kujtim Hoxha created

Change summary

internal/ui/list/list.go  | 12 ++--
internal/ui/model/chat.go | 99 ++++++++++++++++++++++++++++++++++------
internal/ui/model/ui.go   | 88 +++++++++++++++++++++++++++---------
3 files changed, 156 insertions(+), 43 deletions(-)

Detailed changes

internal/ui/list/list.go 🔗

@@ -189,9 +189,9 @@ func (l *List) ScrollBy(lines int) {
 	}
 }
 
-// findVisibleItems finds the range of items that are visible in the viewport.
+// VisibleItemIndices finds the range of items that are visible in the viewport.
 // This is used for checking if selected item is in view.
-func (l *List) findVisibleItems() (startIdx, endIdx int) {
+func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
 	if len(l.items) == 0 {
 		return 0, 0
 	}
@@ -352,7 +352,7 @@ func (l *List) ScrollToSelected() {
 		return
 	}
 
-	startIdx, endIdx := l.findVisibleItems()
+	startIdx, endIdx := l.VisibleItemIndices()
 	if l.selectedIdx < startIdx {
 		// Selected item is above the visible range
 		l.offsetIdx = l.selectedIdx
@@ -385,7 +385,7 @@ func (l *List) SelectedItemInView() bool {
 	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
 		return false
 	}
-	startIdx, endIdx := l.findVisibleItems()
+	startIdx, endIdx := l.VisibleItemIndices()
 	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
 }
 
@@ -453,13 +453,13 @@ func (l *List) SelectedItem() Item {
 
 // SelectFirstInView selects the first item currently in view.
 func (l *List) SelectFirstInView() {
-	startIdx, _ := l.findVisibleItems()
+	startIdx, _ := l.VisibleItemIndices()
 	l.selectedIdx = startIdx
 }
 
 // SelectLastInView selects the last item currently in view.
 func (l *List) SelectLastInView() {
-	_, endIdx := l.findVisibleItems()
+	_, endIdx := l.VisibleItemIndices()
 	l.selectedIdx = endIdx
 }
 

internal/ui/model/chat.go 🔗

@@ -17,6 +17,11 @@ type Chat struct {
 	list     *list.List
 	idInxMap map[string]int // Map of message IDs to their indices in the list
 
+	// Animation visibility optimization: track animations paused due to items
+	// being scrolled out of view. When items become visible again, their
+	// animations are restarted.
+	pausedAnimations map[string]struct{}
+
 	// Mouse state
 	mouseDown     bool
 	mouseDownItem int // Item index where mouse was pressed
@@ -30,7 +35,11 @@ type Chat struct {
 // NewChat creates a new instance of [Chat] that handles chat interactions and
 // messages.
 func NewChat(com *common.Common) *Chat {
-	c := &Chat{com: com, idInxMap: make(map[string]int)}
+	c := &Chat{
+		com:              com,
+		idInxMap:         make(map[string]int),
+		pausedAnimations: make(map[string]struct{}),
+	}
 	l := list.NewList()
 	l.SetGap(1)
 	l.RegisterRenderCallback(c.applyHighlightRange)
@@ -82,17 +91,69 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
 	m.list.AppendItems(items...)
 }
 
-// Animate animated items in the chat list.
+// Animate animates items in the chat list. Only propagates animation messages
+// to visible items to save CPU. When items are not visible, their animation ID
+// is tracked so it can be restarted when they become visible again.
 func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
-	item, ok := m.idInxMap[msg.ID]
-	// Item with the given ID exists
+	idx, ok := m.idInxMap[msg.ID]
+	if !ok {
+		return nil
+	}
+
+	animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
 	if !ok {
 		return nil
 	}
-	if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok {
-		return animatable.Animate(msg)
+
+	// Check if item is currently visible.
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	isVisible := idx >= startIdx && idx <= endIdx
+
+	if !isVisible {
+		// Item not visible - pause animation by not propagating.
+		// Track it so we can restart when it becomes visible.
+		m.pausedAnimations[msg.ID] = struct{}{}
+		return nil
+	}
+
+	// Item is visible - remove from paused set and animate.
+	delete(m.pausedAnimations, msg.ID)
+	return animatable.Animate(msg)
+}
+
+// RestartPausedVisibleAnimations restarts animations for items that were paused
+// due to being scrolled out of view but are now visible again.
+func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
+	if len(m.pausedAnimations) == 0 {
+		return nil
+	}
+
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	var cmds []tea.Cmd
+
+	for id := range m.pausedAnimations {
+		idx, ok := m.idInxMap[id]
+		if !ok {
+			// Item no longer exists.
+			delete(m.pausedAnimations, id)
+			continue
+		}
+
+		if idx >= startIdx && idx <= endIdx {
+			// Item is now visible - restart its animation.
+			if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+			delete(m.pausedAnimations, id)
+		}
+	}
+
+	if len(cmds) == 0 {
+		return nil
 	}
-	return nil
+	return tea.Batch(cmds...)
 }
 
 // Focus sets the focus state of the chat component.
@@ -105,24 +166,32 @@ func (m *Chat) Blur() {
 	m.list.Blur()
 }
 
-// ScrollToTop scrolls the chat view to the top.
-func (m *Chat) ScrollToTop() {
+// ScrollToTop scrolls the chat view to the top and returns a command to restart
+// any paused animations that are now visible.
+func (m *Chat) ScrollToTop() tea.Cmd {
 	m.list.ScrollToTop()
+	return m.RestartPausedVisibleAnimations()
 }
 
-// ScrollToBottom scrolls the chat view to the bottom.
-func (m *Chat) ScrollToBottom() {
+// ScrollToBottom scrolls the chat view to the bottom and returns a command to
+// restart any paused animations that are now visible.
+func (m *Chat) ScrollToBottom() tea.Cmd {
 	m.list.ScrollToBottom()
+	return m.RestartPausedVisibleAnimations()
 }
 
-// ScrollBy scrolls the chat view by the given number of line deltas.
-func (m *Chat) ScrollBy(lines int) {
+// ScrollBy scrolls the chat view by the given number of line deltas and returns
+// a command to restart any paused animations that are now visible.
+func (m *Chat) ScrollBy(lines int) tea.Cmd {
 	m.list.ScrollBy(lines)
+	return m.RestartPausedVisibleAnimations()
 }
 
-// ScrollToSelected scrolls the chat view to the selected item.
-func (m *Chat) ScrollToSelected() {
+// ScrollToSelected scrolls the chat view to the selected item and returns a
+// command to restart any paused animations that are now visible.
+func (m *Chat) ScrollToSelected() tea.Cmd {
 	m.list.ScrollToSelected()
+	return m.RestartPausedVisibleAnimations()
 }
 
 // SelectedItemInView returns whether the selected item is currently in view.

internal/ui/model/ui.go 🔗

@@ -268,16 +268,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch m.state {
 		case uiChat:
 			if msg.Y <= 0 {
-				m.chat.ScrollBy(-1)
+				if cmd := m.chat.ScrollBy(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectPrev()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelected(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			} else if msg.Y >= m.chat.Height()-1 {
-				m.chat.ScrollBy(1)
+				if cmd := m.chat.ScrollBy(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectNext()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelected(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			}
 
@@ -302,16 +310,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case uiChat:
 			switch msg.Button {
 			case tea.MouseWheelUp:
-				m.chat.ScrollBy(-5)
+				if cmd := m.chat.ScrollBy(-5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectPrev()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelected(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			case tea.MouseWheelDown:
-				m.chat.ScrollBy(5)
+				if cmd := m.chat.ScrollBy(5); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectNext()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelected(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			}
 		}
@@ -379,7 +395,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 	}
 
 	m.chat.SetMessages(items...)
-	m.chat.ScrollToBottom()
+	if cmd := m.chat.ScrollToBottom(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
 	m.chat.SelectLast()
 	return tea.Batch(cmds...)
 }
@@ -396,7 +414,9 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 		}
 	}
 	m.chat.AppendMessages(items...)
-	m.chat.ScrollToBottom()
+	if cmd := m.chat.ScrollToBottom(); cmd != nil {
+		cmds = append(cmds, cmd)
+	}
 	return tea.Batch(cmds...)
 }
 
@@ -558,40 +578,64 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				cmds = append(cmds, m.textarea.Focus())
 				m.chat.Blur()
 			case key.Matches(msg, m.keyMap.Chat.Up):
-				m.chat.ScrollBy(-1)
+				if cmd := m.chat.ScrollBy(-1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectPrev()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelected(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			case key.Matches(msg, m.keyMap.Chat.Down):
-				m.chat.ScrollBy(1)
+				if cmd := m.chat.ScrollBy(1); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				if !m.chat.SelectedItemInView() {
 					m.chat.SelectNext()
-					m.chat.ScrollToSelected()
+					if cmd := m.chat.ScrollToSelected(); cmd != nil {
+						cmds = append(cmds, cmd)
+					}
 				}
 			case key.Matches(msg, m.keyMap.Chat.UpOneItem):
 				m.chat.SelectPrev()
-				m.chat.ScrollToSelected()
+				if cmd := m.chat.ScrollToSelected(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			case key.Matches(msg, m.keyMap.Chat.DownOneItem):
 				m.chat.SelectNext()
-				m.chat.ScrollToSelected()
+				if cmd := m.chat.ScrollToSelected(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
-				m.chat.ScrollBy(-m.chat.Height() / 2)
+				if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectFirstInView()
 			case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
-				m.chat.ScrollBy(m.chat.Height() / 2)
+				if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectLastInView()
 			case key.Matches(msg, m.keyMap.Chat.PageUp):
-				m.chat.ScrollBy(-m.chat.Height())
+				if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectFirstInView()
 			case key.Matches(msg, m.keyMap.Chat.PageDown):
-				m.chat.ScrollBy(m.chat.Height())
+				if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectLastInView()
 			case key.Matches(msg, m.keyMap.Chat.Home):
-				m.chat.ScrollToTop()
+				if cmd := m.chat.ScrollToTop(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectFirst()
 			case key.Matches(msg, m.keyMap.Chat.End):
-				m.chat.ScrollToBottom()
+				if cmd := m.chat.ScrollToBottom(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 				m.chat.SelectLast()
 			default:
 				handleGlobalKeys(msg)