on main help sign in
refactor(chat): only show animations for items that are visible
commited18aac98e9f06cc1ad6aa2bef0f779ff894dc43 authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 17 Dec 2025, 18:38 (1 year ago) parentb8d39c6
3 files changed+156−43
internal/ui/list/list.go+6−6 internal/ui/model/chat.go+84−15 internal/ui/model/ui.go+66−22
internal/ui/list/list.go+6−6
@@ -189,9 +189,9 @@ func (l *List) ScrollBy(lines int) {
189189 }
190190}
191191
192−// findVisibleItems finds the range of items that are visible in the viewport.
192+// VisibleItemIndices finds the range of items that are visible in the viewport.
193193// This is used for checking if selected item is in view.
194−func (l *List) findVisibleItems() (startIdx, endIdx int) {
194+func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
195195 if len(l.items) == 0 {
196196 return 0, 0
197197 }
@@ -352,7 +352,7 @@ func (l *List) ScrollToSelected() {
352352 return
353353 }
354354
355− startIdx, endIdx := l.findVisibleItems()
355+ startIdx, endIdx := l.VisibleItemIndices()
356356 if l.selectedIdx < startIdx {
357357 // Selected item is above the visible range
358358 l.offsetIdx = l.selectedIdx
@@ -385,7 +385,7 @@ func (l *List) SelectedItemInView() bool {
385385 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
386386 return false
387387 }
388− startIdx, endIdx := l.findVisibleItems()
388+ startIdx, endIdx := l.VisibleItemIndices()
389389 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
390390}
391391
@@ -453,13 +453,13 @@ func (l *List) SelectedItem() Item {
453453
454454// SelectFirstInView selects the first item currently in view.
455455func (l *List) SelectFirstInView() {
456− startIdx, _ := l.findVisibleItems()
456+ startIdx, _ := l.VisibleItemIndices()
457457 l.selectedIdx = startIdx
458458}
459459
460460// SelectLastInView selects the last item currently in view.
461461func (l *List) SelectLastInView() {
462− _, endIdx := l.findVisibleItems()
462+ _, endIdx := l.VisibleItemIndices()
463463 l.selectedIdx = endIdx
464464}
465465
internal/ui/model/chat.go+84−15
@@ -17,6 +17,11 @@ type Chat struct {
1717 list *list.List
1818 idInxMap map[string]int // Map of message IDs to their indices in the list
1919
20+ // Animation visibility optimization: track animations paused due to items
21+ // being scrolled out of view. When items become visible again, their
22+ // animations are restarted.
23+ pausedAnimations map[string]struct{}
24+
2025 // Mouse state
2126 mouseDown bool
2227 mouseDownItem int // Item index where mouse was pressed
@@ -30,7 +35,11 @@ type Chat struct {
3035// NewChat creates a new instance of [Chat] that handles chat interactions and
3136// messages.
3237func NewChat(com *common.Common) *Chat {
33− c := &Chat{com: com, idInxMap: make(map[string]int)}
38+ c := &Chat{
39+ com: com,
40+ idInxMap: make(map[string]int),
41+ pausedAnimations: make(map[string]struct{}),
42+ }
3443 l := list.NewList()
3544 l.SetGap(1)
3645 l.RegisterRenderCallback(c.applyHighlightRange)
@@ -82,17 +91,69 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
8291 m.list.AppendItems(items...)
8392}
8493
85−// Animate animated items in the chat list.
94+// Animate animates items in the chat list. Only propagates animation messages
95+// to visible items to save CPU. When items are not visible, their animation ID
96+// is tracked so it can be restarted when they become visible again.
8697func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
87− item, ok := m.idInxMap[msg.ID]
88− // Item with the given ID exists
98+ idx, ok := m.idInxMap[msg.ID]
99+ if !ok {
100+ return nil
101+ }
102+
103+ animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
89104 if !ok {
90105 return nil
91106 }
92− if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok {
93− return animatable.Animate(msg)
107+
108+ // Check if item is currently visible.
109+ startIdx, endIdx := m.list.VisibleItemIndices()
110+ isVisible := idx >= startIdx && idx <= endIdx
111+
112+ if !isVisible {
113+ // Item not visible - pause animation by not propagating.
114+ // Track it so we can restart when it becomes visible.
115+ m.pausedAnimations[msg.ID] = struct{}{}
116+ return nil
117+ }
118+
119+ // Item is visible - remove from paused set and animate.
120+ delete(m.pausedAnimations, msg.ID)
121+ return animatable.Animate(msg)
122+}
123+
124+// RestartPausedVisibleAnimations restarts animations for items that were paused
125+// due to being scrolled out of view but are now visible again.
126+func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
127+ if len(m.pausedAnimations) == 0 {
128+ return nil
129+ }
130+
131+ startIdx, endIdx := m.list.VisibleItemIndices()
132+ var cmds []tea.Cmd
133+
134+ for id := range m.pausedAnimations {
135+ idx, ok := m.idInxMap[id]
136+ if !ok {
137+ // Item no longer exists.
138+ delete(m.pausedAnimations, id)
139+ continue
140+ }
141+
142+ if idx >= startIdx && idx <= endIdx {
143+ // Item is now visible - restart its animation.
144+ if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
145+ if cmd := animatable.StartAnimation(); cmd != nil {
146+ cmds = append(cmds, cmd)
147+ }
148+ }
149+ delete(m.pausedAnimations, id)
150+ }
151+ }
152+
153+ if len(cmds) == 0 {
154+ return nil
94155 }
95− return nil
156+ return tea.Batch(cmds...)
96157}
97158
98159// Focus sets the focus state of the chat component.
@@ -105,24 +166,32 @@ func (m *Chat) Blur() {
105166 m.list.Blur()
106167}
107168
108−// ScrollToTop scrolls the chat view to the top.
109−func (m *Chat) ScrollToTop() {
169+// ScrollToTop scrolls the chat view to the top and returns a command to restart
170+// any paused animations that are now visible.
171+func (m *Chat) ScrollToTop() tea.Cmd {
110172 m.list.ScrollToTop()
173+ return m.RestartPausedVisibleAnimations()
111174}
112175
113−// ScrollToBottom scrolls the chat view to the bottom.
114−func (m *Chat) ScrollToBottom() {
176+// ScrollToBottom scrolls the chat view to the bottom and returns a command to
177+// restart any paused animations that are now visible.
178+func (m *Chat) ScrollToBottom() tea.Cmd {
115179 m.list.ScrollToBottom()
180+ return m.RestartPausedVisibleAnimations()
116181}
117182
118−// ScrollBy scrolls the chat view by the given number of line deltas.
119−func (m *Chat) ScrollBy(lines int) {
183+// ScrollBy scrolls the chat view by the given number of line deltas and returns
184+// a command to restart any paused animations that are now visible.
185+func (m *Chat) ScrollBy(lines int) tea.Cmd {
120186 m.list.ScrollBy(lines)
187+ return m.RestartPausedVisibleAnimations()
121188}
122189
123−// ScrollToSelected scrolls the chat view to the selected item.
124−func (m *Chat) ScrollToSelected() {
190+// ScrollToSelected scrolls the chat view to the selected item and returns a
191+// command to restart any paused animations that are now visible.
192+func (m *Chat) ScrollToSelected() tea.Cmd {
125193 m.list.ScrollToSelected()
194+ return m.RestartPausedVisibleAnimations()
126195}
127196
128197// SelectedItemInView returns whether the selected item is currently in view.
internal/ui/model/ui.go+66−22
@@ -268,16 +268,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
268268 switch m.state {
269269 case uiChat:
270270 if msg.Y <= 0 {
271− m.chat.ScrollBy(-1)
271+ if cmd := m.chat.ScrollBy(-1); cmd != nil {
272+ cmds = append(cmds, cmd)
273+ }
272274 if !m.chat.SelectedItemInView() {
273275 m.chat.SelectPrev()
274− m.chat.ScrollToSelected()
276+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
277+ cmds = append(cmds, cmd)
278+ }
275279 }
276280 } else if msg.Y >= m.chat.Height()-1 {
277− m.chat.ScrollBy(1)
281+ if cmd := m.chat.ScrollBy(1); cmd != nil {
282+ cmds = append(cmds, cmd)
283+ }
278284 if !m.chat.SelectedItemInView() {
279285 m.chat.SelectNext()
280− m.chat.ScrollToSelected()
286+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
287+ cmds = append(cmds, cmd)
288+ }
281289 }
282290 }
283291
@@ -302,16 +310,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
302310 case uiChat:
303311 switch msg.Button {
304312 case tea.MouseWheelUp:
305− m.chat.ScrollBy(-5)
313+ if cmd := m.chat.ScrollBy(-5); cmd != nil {
314+ cmds = append(cmds, cmd)
315+ }
306316 if !m.chat.SelectedItemInView() {
307317 m.chat.SelectPrev()
308− m.chat.ScrollToSelected()
318+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
319+ cmds = append(cmds, cmd)
320+ }
309321 }
310322 case tea.MouseWheelDown:
311− m.chat.ScrollBy(5)
323+ if cmd := m.chat.ScrollBy(5); cmd != nil {
324+ cmds = append(cmds, cmd)
325+ }
312326 if !m.chat.SelectedItemInView() {
313327 m.chat.SelectNext()
314− m.chat.ScrollToSelected()
328+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
329+ cmds = append(cmds, cmd)
330+ }
315331 }
316332 }
317333 }
@@ -379,7 +395,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
379395 }
380396
381397 m.chat.SetMessages(items...)
382− m.chat.ScrollToBottom()
398+ if cmd := m.chat.ScrollToBottom(); cmd != nil {
399+ cmds = append(cmds, cmd)
400+ }
383401 m.chat.SelectLast()
384402 return tea.Batch(cmds...)
385403}
@@ -396,7 +414,9 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
396414 }
397415 }
398416 m.chat.AppendMessages(items...)
399− m.chat.ScrollToBottom()
417+ if cmd := m.chat.ScrollToBottom(); cmd != nil {
418+ cmds = append(cmds, cmd)
419+ }
400420 return tea.Batch(cmds...)
401421}
402422
@@ -558,40 +578,64 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
558578 cmds = append(cmds, m.textarea.Focus())
559579 m.chat.Blur()
560580 case key.Matches(msg, m.keyMap.Chat.Up):
561− m.chat.ScrollBy(-1)
581+ if cmd := m.chat.ScrollBy(-1); cmd != nil {
582+ cmds = append(cmds, cmd)
583+ }
562584 if !m.chat.SelectedItemInView() {
563585 m.chat.SelectPrev()
564− m.chat.ScrollToSelected()
586+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
587+ cmds = append(cmds, cmd)
588+ }
565589 }
566590 case key.Matches(msg, m.keyMap.Chat.Down):
567− m.chat.ScrollBy(1)
591+ if cmd := m.chat.ScrollBy(1); cmd != nil {
592+ cmds = append(cmds, cmd)
593+ }
568594 if !m.chat.SelectedItemInView() {
569595 m.chat.SelectNext()
570− m.chat.ScrollToSelected()
596+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
597+ cmds = append(cmds, cmd)
598+ }
571599 }
572600 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
573601 m.chat.SelectPrev()
574− m.chat.ScrollToSelected()
602+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
603+ cmds = append(cmds, cmd)
604+ }
575605 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
576606 m.chat.SelectNext()
577− m.chat.ScrollToSelected()
607+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
608+ cmds = append(cmds, cmd)
609+ }
578610 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
579− m.chat.ScrollBy(-m.chat.Height() / 2)
611+ if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil {
612+ cmds = append(cmds, cmd)
613+ }
580614 m.chat.SelectFirstInView()
581615 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
582− m.chat.ScrollBy(m.chat.Height() / 2)
616+ if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil {
617+ cmds = append(cmds, cmd)
618+ }
583619 m.chat.SelectLastInView()
584620 case key.Matches(msg, m.keyMap.Chat.PageUp):
585− m.chat.ScrollBy(-m.chat.Height())
621+ if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil {
622+ cmds = append(cmds, cmd)
623+ }
586624 m.chat.SelectFirstInView()
587625 case key.Matches(msg, m.keyMap.Chat.PageDown):
588− m.chat.ScrollBy(m.chat.Height())
626+ if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil {
627+ cmds = append(cmds, cmd)
628+ }
589629 m.chat.SelectLastInView()
590630 case key.Matches(msg, m.keyMap.Chat.Home):
591− m.chat.ScrollToTop()
631+ if cmd := m.chat.ScrollToTop(); cmd != nil {
632+ cmds = append(cmds, cmd)
633+ }
592634 m.chat.SelectFirst()
593635 case key.Matches(msg, m.keyMap.Chat.End):
594− m.chat.ScrollToBottom()
636+ if cmd := m.chat.ScrollToBottom(); cmd != nil {
637+ cmds = append(cmds, cmd)
638+ }
595639 m.chat.SelectLast()
596640 default:
597641 handleGlobalKeys(msg)