From ed18aac98e9f06cc1ad6aa2bef0f779ff894dc43 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 18:38:34 +0100 Subject: [PATCH] refactor(chat): only show animations for items that are visible --- 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(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 17766cd52506132322c051fffaf33de613332315..3e9fe124b0ddf1e55b3e920bc2828d4efabbc996 100644 --- a/internal/ui/list/list.go +++ b/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 } diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 5fa2dc8ed1d6ba4213daa6e9a7f198761e2d0568..76b52ce7be311656a1e546bb6f5e261333c95e0a 100644 --- a/internal/ui/model/chat.go +++ b/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. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2caa9ac9b910a4439d1d69a62c595dce94239695..dc581d4febe19a73ae12618a44f9ccd8bdf802b8 100644 --- a/internal/ui/model/ui.go +++ b/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)