on main help sign in
fix(ui): ensure the message list does not scroll beyond the last item (#1993)
5e384b2Ayman Bagabas
internal/ui/list/list.go+51−63
@@ -75,30 +75,24 @@ func (l *List) Gap() int {
75 return l.gap
75 return l.gap
76}
76}
77
77
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.
79func (l *List) AtBottom() bool {
79func (l *List) AtBottom() bool {
80 if len(l.items) == 0 {
80 if len(l.items) == 0 {
81 return true
81 return true
82 }
82 }
83
83
84− // Calculate total height of all items from the bottom.
84+ // Calculate the height from offsetIdx to the end.
85 var totalHeight int
85 var totalHeight int
86− for i := len(l.items) - 1; i >= 0; i-- {
86+ for idx := l.offsetIdx; idx < len(l.items); idx++ {
87− item := l.getItem(i)
87+ item := l.getItem(idx)
88− totalHeight += item.height
88+ itemHeight := item.height
89− if l.gap > 0 && i < len(l.items)-1 {
89+ if l.gap > 0 && idx > l.offsetIdx {
90− totalHeight += l.gap
90+ itemHeight += 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
97 }
91 }
92+ totalHeight += itemHeight
98 }
93 }
99
94
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
102}
96}
103
97
104// SetReverse shows the list in reverse order.
98// SetReverse shows the list in reverse order.
@@ -121,6 +115,30 @@ func (l *List) Len() int {
121 return len(l.items)
115 return len(l.items)
122}
116}
123
117
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+
124// getItem renders (if needed) and returns the item at the given index.
142// getItem renders (if needed) and returns the item at the given index.
125func (l *List) getItem(idx int) renderedItem {
143func (l *List) getItem(idx int) renderedItem {
126 if idx < 0 || idx >= len(l.items) {
144 if idx < 0 || idx >= len(l.items) {
@@ -171,44 +189,29 @@ func (l *List) ScrollBy(lines int) {
171
189
172 if lines > 0 {
190 if lines > 0 {
173 // Scroll down
191 // 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
192 l.offsetLine += lines
192 l.offsetLine += lines
193− for {
194− item = l.getItem(l.offsetIdx)
193+ currentItem := l.getItem(l.offsetIdx)
195− totalHeight := item.height
194+ for l.offsetLine >= currentItem.height {
195+ l.offsetLine -= currentItem.height
196 if l.gap > 0 {
196 if l.gap > 0 {
197− totalHeight += l.gap
197+ l.offsetLine -= l.gap
198− }
199−
200− if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
201− // Valid offset
202− break
203 }
198 }
204
199
205 // Move to next item
200 // Move to next item
206− l.offsetLine -= totalHeight
207 l.offsetIdx++
201 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)
208 }
208 }
209
209
210− if 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
211− l.offsetLine = item.height
214+ l.offsetLine = lastOffsetLine
212 }
215 }
213 } else if lines < 0 {
216 } else if lines < 0 {
214 // Scroll up
217 // Scroll up
@@ -408,24 +411,9 @@ func (l *List) ScrollToBottom() {
408 return
411 return
409 }
412 }
410
413
411− // Scroll to the last item
414+ lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
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
415+ l.offsetIdx = lastOffsetIdx
421− l.offsetLine = totalHeight - l.height
416+ l.offsetLine = lastOffsetLine
422− break
423− }
424− }
425− if totalHeight < l.height {
426− // All items fit in the viewport
427− l.ScrollToTop()
428− }
429}
417}
430
418
431// ScrollToSelected scrolls the list to the selected item.
419// 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) {
66// SetSize sets the size of the chat view port.
66// SetSize sets the size of the chat view port.
67func (m *Chat) SetSize(width, height int) {
67func (m *Chat) SetSize(width, height int) {
68 m.list.SetSize(width, height)
68 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+ }
69}
73}
70
74
71// Len returns the number of items in the chat list.
75// Len returns the number of items in the chat list.