on main help sign in
refactor(chat): only show animations for items that are visible
ed18aacKujtim Hoxha
internal/ui/list/list.go+6−6
@@ -189,9 +189,9 @@ func (l *List) ScrollBy(lines int) {
189 }
189 }
190}
190}
191
191
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.
193// This is used for checking if selected item is in view.
193// 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) {
195 if len(l.items) == 0 {
195 if len(l.items) == 0 {
196 return 0, 0
196 return 0, 0
197 }
197 }
@@ -352,7 +352,7 @@ func (l *List) ScrollToSelected() {
352 return
352 return
353 }
353 }
354
354
355− startIdx, endIdx := l.findVisibleItems()
355+ startIdx, endIdx := l.VisibleItemIndices()
356 if l.selectedIdx < startIdx {
356 if l.selectedIdx < startIdx {
357 // Selected item is above the visible range
357 // Selected item is above the visible range
358 l.offsetIdx = l.selectedIdx
358 l.offsetIdx = l.selectedIdx
@@ -385,7 +385,7 @@ func (l *List) SelectedItemInView() bool {
385 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
385 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
386 return false
386 return false
387 }
387 }
388− startIdx, endIdx := l.findVisibleItems()
388+ startIdx, endIdx := l.VisibleItemIndices()
389 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
389 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
390}
390}
391
391
@@ -453,13 +453,13 @@ func (l *List) SelectedItem() Item {
453
453
454// SelectFirstInView selects the first item currently in view.
454// SelectFirstInView selects the first item currently in view.
455func (l *List) SelectFirstInView() {
455func (l *List) SelectFirstInView() {
456− startIdx, _ := l.findVisibleItems()
456+ startIdx, _ := l.VisibleItemIndices()
457 l.selectedIdx = startIdx
457 l.selectedIdx = startIdx
458}
458}
459
459
460// SelectLastInView selects the last item currently in view.
460// SelectLastInView selects the last item currently in view.
461func (l *List) SelectLastInView() {
461func (l *List) SelectLastInView() {
462− _, endIdx := l.findVisibleItems()
462+ _, endIdx := l.VisibleItemIndices()
463 l.selectedIdx = endIdx
463 l.selectedIdx = endIdx
464}
464}
465
465
internal/ui/model/chat.go+84−15
@@ -17,6 +17,11 @@ type Chat struct {
17 list *list.List
17 list *list.List
18 idInxMap map[string]int // Map of message IDs to their indices in the list
18 idInxMap map[string]int // Map of message IDs to their indices in the list
19
19
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+
20 // Mouse state
25 // Mouse state
21 mouseDown bool
26 mouseDown bool
22 mouseDownItem int // Item index where mouse was pressed
27 mouseDownItem int // Item index where mouse was pressed
@@ -30,7 +35,11 @@ type Chat struct {
30// NewChat creates a new instance of [Chat] that handles chat interactions and
35// NewChat creates a new instance of [Chat] that handles chat interactions and
31// messages.
36// messages.
32func NewChat(com *common.Common) *Chat {
37func NewChat(com *common.Common) *Chat {
38+ c := &Chat{
39+ com: com,
33− c := &Chat{com: com, idInxMap: make(map[string]int)}
40+ idInxMap: make(map[string]int),
41+ pausedAnimations: make(map[string]struct{}),
42+ }
34 l := list.NewList()
43 l := list.NewList()
35 l.SetGap(1)
44 l.SetGap(1)
36 l.RegisterRenderCallback(c.applyHighlightRange)
45 l.RegisterRenderCallback(c.applyHighlightRange)
@@ -82,17 +91,69 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
82 m.list.AppendItems(items...)
91 m.list.AppendItems(items...)
83}
92}
84
93
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.
86func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
97func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
87− item, ok := m.idInxMap[msg.ID]
98+ idx, ok := m.idInxMap[msg.ID]
88− // Item with the given ID exists
99+ if !ok {
100+ return nil
101+ }
102+
103+ animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
89 if !ok {
104 if !ok {
90 return nil
105 return nil
91 }
106 }
92− if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok {
107+
93− return animatable.Animate(msg)
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
94 }
155 }
95− return nil
156+ return tea.Batch(cmds...)
96}
157}
97
158
98// Focus sets the focus state of the chat component.
159// Focus sets the focus state of the chat component.
@@ -105,24 +166,32 @@ func (m *Chat) Blur() {
105 m.list.Blur()
166 m.list.Blur()
106}
167}
107
168
108−// ScrollToTop scrolls the chat view to the top.
169+// ScrollToTop scrolls the chat view to the top and returns a command to restart
170+// any paused animations that are now visible.
109−func (m *Chat) ScrollToTop() {
171+func (m *Chat) ScrollToTop() tea.Cmd {
110 m.list.ScrollToTop()
172 m.list.ScrollToTop()
173+ return m.RestartPausedVisibleAnimations()
111}
174}
112
175
113−// ScrollToBottom scrolls the chat view to the bottom.
176+// ScrollToBottom scrolls the chat view to the bottom and returns a command to
177+// restart any paused animations that are now visible.
114−func (m *Chat) ScrollToBottom() {
178+func (m *Chat) ScrollToBottom() tea.Cmd {
115 m.list.ScrollToBottom()
179 m.list.ScrollToBottom()
180+ return m.RestartPausedVisibleAnimations()
116}
181}
117
182
118−// ScrollBy scrolls the chat view by the given number of line deltas.
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.
119−func (m *Chat) ScrollBy(lines int) {
185+func (m *Chat) ScrollBy(lines int) tea.Cmd {
120 m.list.ScrollBy(lines)
186 m.list.ScrollBy(lines)
187+ return m.RestartPausedVisibleAnimations()
121}
188}
122
189
123−// ScrollToSelected scrolls the chat view to the selected item.
190+// ScrollToSelected scrolls the chat view to the selected item and returns a
191+// command to restart any paused animations that are now visible.
124−func (m *Chat) ScrollToSelected() {
192+func (m *Chat) ScrollToSelected() tea.Cmd {
125 m.list.ScrollToSelected()
193 m.list.ScrollToSelected()
194+ return m.RestartPausedVisibleAnimations()
126}
195}
127
196
128// SelectedItemInView returns whether the selected item is currently in view.
197// 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) {
268 switch m.state {
268 switch m.state {
269 case uiChat:
269 case uiChat:
270 if msg.Y <= 0 {
270 if msg.Y <= 0 {
271− m.chat.ScrollBy(-1)
271+ if cmd := m.chat.ScrollBy(-1); cmd != nil {
272+ cmds = append(cmds, cmd)
273+ }
272 if !m.chat.SelectedItemInView() {
274 if !m.chat.SelectedItemInView() {
273 m.chat.SelectPrev()
275 m.chat.SelectPrev()
274− m.chat.ScrollToSelected()
276+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
277+ cmds = append(cmds, cmd)
278+ }
275 }
279 }
276 } else if msg.Y >= m.chat.Height()-1 {
280 } 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+ }
278 if !m.chat.SelectedItemInView() {
284 if !m.chat.SelectedItemInView() {
279 m.chat.SelectNext()
285 m.chat.SelectNext()
280− m.chat.ScrollToSelected()
286+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
287+ cmds = append(cmds, cmd)
288+ }
281 }
289 }
282 }
290 }
283
291
@@ -302,16 +310,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
302 case uiChat:
310 case uiChat:
303 switch msg.Button {
311 switch msg.Button {
304 case tea.MouseWheelUp:
312 case tea.MouseWheelUp:
305− m.chat.ScrollBy(-5)
313+ if cmd := m.chat.ScrollBy(-5); cmd != nil {
314+ cmds = append(cmds, cmd)
315+ }
306 if !m.chat.SelectedItemInView() {
316 if !m.chat.SelectedItemInView() {
307 m.chat.SelectPrev()
317 m.chat.SelectPrev()
308− m.chat.ScrollToSelected()
318+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
319+ cmds = append(cmds, cmd)
320+ }
309 }
321 }
310 case tea.MouseWheelDown:
322 case tea.MouseWheelDown:
311− m.chat.ScrollBy(5)
323+ if cmd := m.chat.ScrollBy(5); cmd != nil {
324+ cmds = append(cmds, cmd)
325+ }
312 if !m.chat.SelectedItemInView() {
326 if !m.chat.SelectedItemInView() {
313 m.chat.SelectNext()
327 m.chat.SelectNext()
314− m.chat.ScrollToSelected()
328+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
329+ cmds = append(cmds, cmd)
330+ }
315 }
331 }
316 }
332 }
317 }
333 }
@@ -379,7 +395,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
379 }
395 }
380
396
381 m.chat.SetMessages(items...)
397 m.chat.SetMessages(items...)
382− m.chat.ScrollToBottom()
398+ if cmd := m.chat.ScrollToBottom(); cmd != nil {
399+ cmds = append(cmds, cmd)
400+ }
383 m.chat.SelectLast()
401 m.chat.SelectLast()
384 return tea.Batch(cmds...)
402 return tea.Batch(cmds...)
385}
403}
@@ -396,7 +414,9 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
396 }
414 }
397 }
415 }
398 m.chat.AppendMessages(items...)
416 m.chat.AppendMessages(items...)
399− m.chat.ScrollToBottom()
417+ if cmd := m.chat.ScrollToBottom(); cmd != nil {
418+ cmds = append(cmds, cmd)
419+ }
400 return tea.Batch(cmds...)
420 return tea.Batch(cmds...)
401}
421}
402
422
@@ -558,40 +578,64 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
558 cmds = append(cmds, m.textarea.Focus())
578 cmds = append(cmds, m.textarea.Focus())
559 m.chat.Blur()
579 m.chat.Blur()
560 case key.Matches(msg, m.keyMap.Chat.Up):
580 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+ }
562 if !m.chat.SelectedItemInView() {
584 if !m.chat.SelectedItemInView() {
563 m.chat.SelectPrev()
585 m.chat.SelectPrev()
564− m.chat.ScrollToSelected()
586+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
587+ cmds = append(cmds, cmd)
588+ }
565 }
589 }
566 case key.Matches(msg, m.keyMap.Chat.Down):
590 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+ }
568 if !m.chat.SelectedItemInView() {
594 if !m.chat.SelectedItemInView() {
569 m.chat.SelectNext()
595 m.chat.SelectNext()
570− m.chat.ScrollToSelected()
596+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
597+ cmds = append(cmds, cmd)
598+ }
571 }
599 }
572 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
600 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
573 m.chat.SelectPrev()
601 m.chat.SelectPrev()
574− m.chat.ScrollToSelected()
602+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
603+ cmds = append(cmds, cmd)
604+ }
575 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
605 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
576 m.chat.SelectNext()
606 m.chat.SelectNext()
577− m.chat.ScrollToSelected()
607+ if cmd := m.chat.ScrollToSelected(); cmd != nil {
608+ cmds = append(cmds, cmd)
609+ }
578 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
610 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+ }
580 m.chat.SelectFirstInView()
614 m.chat.SelectFirstInView()
581 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
615 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+ }
583 m.chat.SelectLastInView()
619 m.chat.SelectLastInView()
584 case key.Matches(msg, m.keyMap.Chat.PageUp):
620 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+ }
586 m.chat.SelectFirstInView()
624 m.chat.SelectFirstInView()
587 case key.Matches(msg, m.keyMap.Chat.PageDown):
625 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+ }
589 m.chat.SelectLastInView()
629 m.chat.SelectLastInView()
590 case key.Matches(msg, m.keyMap.Chat.Home):
630 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+ }
592 m.chat.SelectFirst()
634 m.chat.SelectFirst()
593 case key.Matches(msg, m.keyMap.Chat.End):
635 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+ }
595 m.chat.SelectLast()
639 m.chat.SelectLast()
596 default:
640 default:
597 handleGlobalKeys(msg)
641 handleGlobalKeys(msg)