From 5e384b2e8f7ba72395581d164c18e232152e1023 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 27 Jan 2026 09:12:25 -0500 Subject: [PATCH] fix(ui): ensure the message list does not scroll beyond the last item (#1993) * fix(ui): ensure the message list does not scroll beyond the last item Ensure that when scrolling down, the message list does not scroll beyond the last item, preventing empty space from appearing below the last message. * fix: lint --------- Co-authored-by: Kujtim Hoxha --- internal/ui/list/list.go | 114 +++++++++++++++++--------------------- internal/ui/model/chat.go | 4 ++ 2 files changed, 55 insertions(+), 63 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index a731a0a30023c451f2e1067e4e15ccb5e06ea177..0883ab2b56c5bb7ab26073301890c832e7c4e441 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -75,30 +75,24 @@ func (l *List) Gap() int { return l.gap } -// AtBottom returns whether the list is scrolled to the bottom. +// AtBottom returns whether the list is showing the last item at the bottom. func (l *List) AtBottom() bool { if len(l.items) == 0 { return true } - // Calculate total height of all items from the bottom. + // Calculate the height from offsetIdx to the end. var totalHeight int - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalHeight += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalHeight += l.gap - } - if totalHeight >= l.height { - // This is the expected bottom position. - expectedIdx := i - expectedLine := totalHeight - l.height - return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine + for idx := l.offsetIdx; idx < len(l.items); idx++ { + item := l.getItem(idx) + itemHeight := item.height + if l.gap > 0 && idx > l.offsetIdx { + itemHeight += l.gap } + totalHeight += itemHeight } - // All items fit in viewport - we're at bottom if at top. - return l.offsetIdx == 0 && l.offsetLine == 0 + return totalHeight-l.offsetLine <= l.height } // SetReverse shows the list in reverse order. @@ -121,6 +115,30 @@ func (l *List) Len() int { return len(l.items) } +// lastOffsetItem returns the index and line offsets of the last item that can +// be partially visible in the viewport. +func (l *List) lastOffsetItem() (int, int, int) { + var totalHeight int + var idx int + for idx = len(l.items) - 1; idx >= 0; idx-- { + item := l.getItem(idx) + itemHeight := item.height + if l.gap > 0 && idx < len(l.items)-1 { + itemHeight += l.gap + } + totalHeight += itemHeight + if totalHeight > l.height { + break + } + } + + // Calculate line offset within the item + lineOffset := max(totalHeight-l.height, 0) + idx = max(idx, 0) + + return idx, lineOffset, totalHeight +} + // getItem renders (if needed) and returns the item at the given index. func (l *List) getItem(idx int) renderedItem { if idx < 0 || idx >= len(l.items) { @@ -171,44 +189,29 @@ func (l *List) ScrollBy(lines int) { if lines > 0 { // Scroll down - // Calculate from the bottom how many lines needed to anchor the last - // item to the bottom - var totalLines int - var lastItemIdx int // the last item that can be partially visible - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalLines += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalLines += l.gap - } - if totalLines > l.height-1 { - lastItemIdx = i - break - } - } - - // Now scroll down by lines - var item renderedItem l.offsetLine += lines - for { - item = l.getItem(l.offsetIdx) - totalHeight := item.height + currentItem := l.getItem(l.offsetIdx) + for l.offsetLine >= currentItem.height { + l.offsetLine -= currentItem.height if l.gap > 0 { - totalHeight += l.gap - } - - if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight { - // Valid offset - break + l.offsetLine -= l.gap } // Move to next item - l.offsetLine -= totalHeight l.offsetIdx++ + if l.offsetIdx > len(l.items)-1 { + // Reached bottom + l.ScrollToBottom() + return + } + currentItem = l.getItem(l.offsetIdx) } - if l.offsetLine >= item.height { - l.offsetLine = item.height + lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem() + if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) { + // Clamp to bottom + l.offsetIdx = lastOffsetIdx + l.offsetLine = lastOffsetLine } } else if lines < 0 { // Scroll up @@ -408,24 +411,9 @@ func (l *List) ScrollToBottom() { return } - // Scroll to the last item - var totalHeight int - for i := len(l.items) - 1; i >= 0; i-- { - item := l.getItem(i) - totalHeight += item.height - if l.gap > 0 && i < len(l.items)-1 { - totalHeight += l.gap - } - if totalHeight >= l.height { - l.offsetIdx = i - l.offsetLine = totalHeight - l.height - break - } - } - if totalHeight < l.height { - // All items fit in the viewport - l.ScrollToTop() - } + lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem() + l.offsetIdx = lastOffsetIdx + l.offsetLine = lastOffsetLine } // ScrollToSelected scrolls the list to the selected item. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index d009a261580eaed209c1fc15966f50f4a8b3e62d..3a743edd9d1e87b643076f114b065b2eaa2b2ca5 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -66,6 +66,10 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { // SetSize sets the size of the chat view port. func (m *Chat) SetSize(width, height int) { m.list.SetSize(width, height) + // Anchor to bottom if we were at the bottom. + if m.list.AtBottom() { + m.list.ScrollToBottom() + } } // Len returns the number of items in the chat list.