@@ -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
}
@@ -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.
@@ -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)