on main help sign in
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 <kujtimii.h@gmail.com>

commit5e384b2e8f7ba72395581d164c18e232152e1023 authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 27 Jan 2026, 09:12 (9 months ago) parent4a0af71
2 files changed+55−63
internal/ui/list/list.go+51−63 internal/ui/model/chat.go+4
internal/ui/list/list.go+51−63
@@ -75,30 +75,24 @@ func (l *List) Gap() int {
7575 return l.gap
7676}
7777
78−// AtBottom returns whether the list is scrolled to the bottom.
78+// AtBottom returns whether the list is showing the last item at the bottom.
7979func (l *List) AtBottom() bool {
8080 if len(l.items) == 0 {
8181 return true
8282 }
8383
84− // Calculate total height of all items from the bottom.
84+ // Calculate the height from offsetIdx to the end.
8585 var totalHeight int
86− for i := len(l.items) - 1; i >= 0; i-- {
87− item := l.getItem(i)
88− totalHeight += item.height
89− if l.gap > 0 && i < len(l.items)-1 {
90− totalHeight += l.gap
91− }
92− if totalHeight >= l.height {
93− // This is the expected bottom position.
94− expectedIdx := i
95− expectedLine := totalHeight - l.height
96− return l.offsetIdx == expectedIdx && l.offsetLine >= expectedLine
86+ for idx := l.offsetIdx; idx < len(l.items); idx++ {
87+ item := l.getItem(idx)
88+ itemHeight := item.height
89+ if l.gap > 0 && idx > l.offsetIdx {
90+ itemHeight += l.gap
9791 }
92+ totalHeight += itemHeight
9893 }
9994
100− // All items fit in viewport - we're at bottom if at top.
101− return l.offsetIdx == 0 && l.offsetLine == 0
95+ return totalHeight-l.offsetLine <= l.height
10296}
10397
10498// SetReverse shows the list in reverse order.
@@ -121,6 +115,30 @@ func (l *List) Len() int {
121115 return len(l.items)
122116}
123117
118+// lastOffsetItem returns the index and line offsets of the last item that can
119+// be partially visible in the viewport.
120+func (l *List) lastOffsetItem() (int, int, int) {
121+ var totalHeight int
122+ var idx int
123+ for idx = len(l.items) - 1; idx >= 0; idx-- {
124+ item := l.getItem(idx)
125+ itemHeight := item.height
126+ if l.gap > 0 && idx < len(l.items)-1 {
127+ itemHeight += l.gap
128+ }
129+ totalHeight += itemHeight
130+ if totalHeight > l.height {
131+ break
132+ }
133+ }
134+
135+ // Calculate line offset within the item
136+ lineOffset := max(totalHeight-l.height, 0)
137+ idx = max(idx, 0)
138+
139+ return idx, lineOffset, totalHeight
140+}
141+
124142// getItem renders (if needed) and returns the item at the given index.
125143func (l *List) getItem(idx int) renderedItem {
126144 if idx < 0 || idx >= len(l.items) {
@@ -171,44 +189,29 @@ func (l *List) ScrollBy(lines int) {
171189
172190 if lines > 0 {
173191 // Scroll down
174− // Calculate from the bottom how many lines needed to anchor the last
175− // item to the bottom
176− var totalLines int
177− var lastItemIdx int // the last item that can be partially visible
178− for i := len(l.items) - 1; i >= 0; i-- {
179− item := l.getItem(i)
180− totalLines += item.height
181− if l.gap > 0 && i < len(l.items)-1 {
182− totalLines += l.gap
183− }
184− if totalLines > l.height-1 {
185− lastItemIdx = i
186− break
187− }
188− }
189−
190− // Now scroll down by lines
191− var item renderedItem
192192 l.offsetLine += lines
193− for {
194− item = l.getItem(l.offsetIdx)
195− totalHeight := item.height
193+ currentItem := l.getItem(l.offsetIdx)
194+ for l.offsetLine >= currentItem.height {
195+ l.offsetLine -= currentItem.height
196196 if l.gap > 0 {
197− totalHeight += l.gap
198− }
199−
200− if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
201− // Valid offset
202− break
197+ l.offsetLine -= l.gap
203198 }
204199
205200 // Move to next item
206− l.offsetLine -= totalHeight
207201 l.offsetIdx++
202+ if l.offsetIdx > len(l.items)-1 {
203+ // Reached bottom
204+ l.ScrollToBottom()
205+ return
206+ }
207+ currentItem = l.getItem(l.offsetIdx)
208208 }
209209
210− if l.offsetLine >= item.height {
211− l.offsetLine = item.height
210+ lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
211+ if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) {
212+ // Clamp to bottom
213+ l.offsetIdx = lastOffsetIdx
214+ l.offsetLine = lastOffsetLine
212215 }
213216 } else if lines < 0 {
214217 // Scroll up
@@ -408,24 +411,9 @@ func (l *List) ScrollToBottom() {
408411 return
409412 }
410413
411− // Scroll to the last item
412− var totalHeight int
413− for i := len(l.items) - 1; i >= 0; i-- {
414− item := l.getItem(i)
415− totalHeight += item.height
416− if l.gap > 0 && i < len(l.items)-1 {
417− totalHeight += l.gap
418− }
419− if totalHeight >= l.height {
420− l.offsetIdx = i
421− l.offsetLine = totalHeight - l.height
422− break
423− }
424− }
425− if totalHeight < l.height {
426− // All items fit in the viewport
427− l.ScrollToTop()
428− }
414+ lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
415+ l.offsetIdx = lastOffsetIdx
416+ l.offsetLine = lastOffsetLine
429417}
430418
431419// ScrollToSelected scrolls the list to the selected item.
internal/ui/model/chat.go+4
@@ -66,6 +66,10 @@ func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
6666// SetSize sets the size of the chat view port.
6767func (m *Chat) SetSize(width, height int) {
6868 m.list.SetSize(width, height)
69+ // Anchor to bottom if we were at the bottom.
70+ if m.list.AtBottom() {
71+ m.list.ScrollToBottom()
72+ }
6973}
7074
7175// Len returns the number of items in the chat list.