1package lazylist
2
3import (
4 "log/slog"
5 "strings"
6)
7
8// List represents a list of items that can be lazily rendered. A list is
9// always rendered like a chat conversation where items are stacked vertically
10// from top to bottom.
11type List struct {
12 // Viewport size
13 width, height int
14
15 // Items in the list
16 items []Item
17
18 // Gap between items (0 or less means no gap)
19 gap int
20
21 // Focus and selection state
22 focused bool
23 selectedIdx int // The current selected index -1 means no selection
24
25 // Rendered content and cache
26 renderedItems map[int]renderedItem
27
28 // offsetIdx is the index of the first visible item in the viewport.
29 offsetIdx int
30 // offsetLine is the number of lines of the item at offsetIdx that are
31 // scrolled out of view (above the viewport).
32 // It must always be >= 0.
33 offsetLine int
34}
35
36// renderedItem holds the rendered content and height of an item.
37type renderedItem struct {
38 content string
39 height int
40}
41
42// NewList creates a new lazy-loaded list.
43func NewList(items ...Item) *List {
44 l := new(List)
45 l.items = items
46 l.renderedItems = make(map[int]renderedItem)
47 return l
48}
49
50// SetSize sets the size of the list viewport.
51func (l *List) SetSize(width, height int) {
52 if width != l.width {
53 l.renderedItems = make(map[int]renderedItem)
54 }
55 l.width = width
56 l.height = height
57 // l.normalizeOffsets()
58}
59
60// SetGap sets the gap between items.
61func (l *List) SetGap(gap int) {
62 l.gap = gap
63}
64
65// Width returns the width of the list viewport.
66func (l *List) Width() int {
67 return l.width
68}
69
70// Height returns the height of the list viewport.
71func (l *List) Height() int {
72 return l.height
73}
74
75// Len returns the number of items in the list.
76func (l *List) Len() int {
77 return len(l.items)
78}
79
80// getItem renders (if needed) and returns the item at the given index.
81func (l *List) getItem(idx int) renderedItem {
82 if idx < 0 || idx >= len(l.items) {
83 return renderedItem{}
84 }
85
86 if item, ok := l.renderedItems[idx]; ok {
87 return item
88 }
89
90 item := l.items[idx]
91 rendered := item.Render(l.width)
92 height := countLines(rendered)
93 // slog.Info("Rendered item", "idx", idx, "height", height)
94
95 ri := renderedItem{
96 content: rendered,
97 height: height,
98 }
99
100 l.renderedItems[idx] = ri
101
102 return ri
103}
104
105// ScrollToIndex scrolls the list to the given item index.
106func (l *List) ScrollToIndex(index int) {
107 if index < 0 {
108 index = 0
109 }
110 if index >= len(l.items) {
111 index = len(l.items) - 1
112 }
113 l.offsetIdx = index
114 l.offsetLine = 0
115}
116
117// ScrollBy scrolls the list by the given number of lines.
118func (l *List) ScrollBy(lines int) {
119 if len(l.items) == 0 || lines == 0 {
120 return
121 }
122
123 if lines > 0 {
124 // Scroll down
125 // Calculate from the bottom how many lines needed to anchor the last
126 // item to the bottom
127 var totalLines int
128 var lastItemIdx int // the last item that can be partially visible
129 for i := len(l.items) - 1; i >= 0; i-- {
130 item := l.getItem(i)
131 totalLines += item.height
132 if l.gap > 0 && i < len(l.items)-1 {
133 totalLines += l.gap
134 }
135 if totalLines >= l.height {
136 lastItemIdx = i
137 break
138 }
139 }
140
141 // Now scroll down by lines
142 var item renderedItem
143 l.offsetLine += lines
144 for {
145 item = l.getItem(l.offsetIdx)
146 totalHeight := item.height
147 if l.gap > 0 {
148 totalHeight += l.gap
149 }
150
151 if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
152 // Valid offset
153 break
154 }
155
156 // Move to next item
157 l.offsetLine -= totalHeight
158 l.offsetIdx++
159 }
160
161 if l.offsetLine >= item.height {
162 l.offsetLine = item.height - 1
163 }
164 } else if lines < 0 {
165 // Scroll up
166 // Calculate from offset how many items needed to fill the viewport
167 // This is needed to know when to stop scrolling up
168 var totalLines int
169 var firstItemIdx int
170 for i := l.offsetIdx; i >= 0; i-- {
171 item := l.getItem(i)
172 totalLines += item.height
173 if l.gap > 0 && i < l.offsetIdx {
174 totalLines += l.gap
175 }
176 if totalLines >= l.height {
177 firstItemIdx = i
178 break
179 }
180 }
181
182 // Now scroll up by lines
183 l.offsetLine += lines // lines is negative
184 for l.offsetIdx > firstItemIdx && l.offsetLine < 0 {
185 // Move to previous item
186 l.offsetIdx--
187 prevItem := l.getItem(l.offsetIdx)
188 totalHeight := prevItem.height
189 if l.gap > 0 {
190 totalHeight += l.gap
191 }
192 l.offsetLine += totalHeight
193 }
194
195 if l.offsetLine < 0 {
196 l.offsetLine = 0
197 }
198 }
199}
200
201// findVisibleItems finds the range of items that are visible in the viewport.
202// This is used for checking if selected item is in view.
203func (l *List) findVisibleItems() (startIdx, endIdx int) {
204 if len(l.items) == 0 {
205 return 0, 0
206 }
207
208 startIdx = l.offsetIdx
209 currentIdx := startIdx
210 visibleHeight := -l.offsetLine
211
212 for currentIdx < len(l.items) {
213 item := l.getItem(currentIdx)
214 visibleHeight += item.height
215 if l.gap > 0 {
216 visibleHeight += l.gap
217 }
218
219 if visibleHeight >= l.height {
220 break
221 }
222 currentIdx++
223 }
224
225 endIdx = currentIdx
226 if endIdx >= len(l.items) {
227 endIdx = len(l.items) - 1
228 }
229
230 return startIdx, endIdx
231}
232
233// Render renders the list and returns the visible lines.
234func (l *List) Render() string {
235 if len(l.items) == 0 {
236 return ""
237 }
238
239 slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height)
240
241 var lines []string
242 currentIdx := l.offsetIdx
243 currentOffset := l.offsetLine
244
245 linesNeeded := l.height
246
247 for linesNeeded > 0 && currentIdx < len(l.items) {
248 item := l.getItem(currentIdx)
249 itemLines := strings.Split(item.content, "\n")
250 itemHeight := len(itemLines)
251
252 if currentOffset < itemHeight {
253 // Add visible content lines
254 lines = append(lines, itemLines[currentOffset:]...)
255
256 // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
257 // But in the loop we can just add it and trim later
258 if l.gap > 0 {
259 for i := 0; i < l.gap; i++ {
260 lines = append(lines, "")
261 }
262 }
263 } else {
264 // offsetLine starts in the gap
265 gapOffset := currentOffset - itemHeight
266 gapRemaining := l.gap - gapOffset
267 if gapRemaining > 0 {
268 for i := 0; i < gapRemaining; i++ {
269 lines = append(lines, "")
270 }
271 }
272 }
273
274 linesNeeded = l.height - len(lines)
275 currentIdx++
276 currentOffset = 0 // Reset offset for subsequent items
277 }
278
279 if len(lines) > l.height {
280 lines = lines[:l.height]
281 }
282
283 return strings.Join(lines, "\n")
284}
285
286// PrependItems prepends items to the list.
287func (l *List) PrependItems(items ...Item) {
288 l.items = append(items, l.items...)
289
290 // Shift cache
291 newCache := make(map[int]renderedItem)
292 for idx, val := range l.renderedItems {
293 newCache[idx+len(items)] = val
294 }
295 l.renderedItems = newCache
296
297 // Keep view position relative to the content that was visible
298 l.offsetIdx += len(items)
299
300 // Update selection index if valid
301 if l.selectedIdx != -1 {
302 l.selectedIdx += len(items)
303 }
304}
305
306// AppendItems appends items to the list.
307func (l *List) AppendItems(items ...Item) {
308 l.items = append(l.items, items...)
309}
310
311// Focus sets the focus state of the list.
312func (l *List) Focus() {
313 l.focused = true
314 if l.selectedIdx < 0 || l.selectedIdx > len(l.items)-1 {
315 return
316 }
317}
318
319// Blur removes the focus state from the list.
320func (l *List) Blur() {
321 l.focused = false
322}
323
324// ScrollToTop scrolls the list to the top.
325func (l *List) ScrollToTop() {
326 l.offsetIdx = 0
327 l.offsetLine = 0
328}
329
330// ScrollToBottom scrolls the list to the bottom.
331func (l *List) ScrollToBottom() {
332 if len(l.items) == 0 {
333 return
334 }
335
336 // Scroll to the last item
337 var totalHeight int
338 for i := len(l.items) - 1; i >= 0; i-- {
339 item := l.getItem(i)
340 totalHeight += item.height
341 if l.gap > 0 && i < len(l.items)-1 {
342 totalHeight += l.gap
343 }
344 if totalHeight >= l.height {
345 l.offsetIdx = i
346 l.offsetLine = totalHeight - l.height
347 break
348 }
349 }
350 if totalHeight < l.height {
351 // All items fit in the viewport
352 l.ScrollToTop()
353 }
354}
355
356// ScrollToSelected scrolls the list to the selected item.
357func (l *List) ScrollToSelected() {
358 // TODO: Implement me
359}
360
361// SelectedItemInView returns whether the selected item is currently in view.
362func (l *List) SelectedItemInView() bool {
363 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
364 return false
365 }
366 startIdx, endIdx := l.findVisibleItems()
367 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
368}
369
370// SetSelected sets the selected item index in the list.
371func (l *List) SetSelected(index int) {
372 if index < 0 || index >= len(l.items) {
373 l.selectedIdx = -1
374 } else {
375 l.selectedIdx = index
376 }
377}
378
379// SelectPrev selects the previous item in the list.
380func (l *List) SelectPrev() {
381 if l.selectedIdx > 0 {
382 l.selectedIdx--
383 }
384}
385
386// SelectNext selects the next item in the list.
387func (l *List) SelectNext() {
388 if l.selectedIdx < len(l.items)-1 {
389 l.selectedIdx++
390 }
391}
392
393// SelectFirst selects the first item in the list.
394func (l *List) SelectFirst() {
395 if len(l.items) > 0 {
396 l.selectedIdx = 0
397 }
398}
399
400// SelectLast selects the last item in the list.
401func (l *List) SelectLast() {
402 if len(l.items) > 0 {
403 l.selectedIdx = len(l.items) - 1
404 }
405}
406
407// SelectFirstInView selects the first item currently in view.
408func (l *List) SelectFirstInView() {
409 startIdx, _ := l.findVisibleItems()
410 l.selectedIdx = startIdx
411}
412
413// SelectLastInView selects the last item currently in view.
414func (l *List) SelectLastInView() {
415 _, endIdx := l.findVisibleItems()
416 l.selectedIdx = endIdx
417}
418
419// HandleMouseDown handles mouse down events at the given line in the viewport.
420func (l *List) HandleMouseDown(x, y int) {
421}
422
423// HandleMouseUp handles mouse up events at the given line in the viewport.
424func (l *List) HandleMouseUp(x, y int) {
425}
426
427// HandleMouseDrag handles mouse drag events at the given line in the viewport.
428func (l *List) HandleMouseDrag(x, y int) {
429}
430
431// countLines counts the number of lines in a string.
432func countLines(s string) int {
433 if s == "" {
434 return 0
435 }
436 return strings.Count(s, "\n") + 1
437}