1package list
2
3import (
4 "strings"
5)
6
7// List represents a list of items that can be lazily rendered. A list is
8// always rendered like a chat conversation where items are stacked vertically
9// from top to bottom.
10type List struct {
11 // Viewport size
12 width, height int
13
14 // Items in the list
15 items []Item
16
17 // Gap between items (0 or less means no gap)
18 gap int
19
20 // show list in reverse order
21 reverse bool
22
23 // Focus and selection state
24 focused bool
25 selectedIdx int // The current selected index -1 means no selection
26
27 // offsetIdx is the index of the first visible item in the viewport.
28 offsetIdx int
29 // offsetLine is the number of lines of the item at offsetIdx that are
30 // scrolled out of view (above the viewport).
31 // It must always be >= 0.
32 offsetLine int
33
34 // renderCallbacks is a list of callbacks to apply when rendering items.
35 renderCallbacks []func(idx, selectedIdx int, item Item) Item
36}
37
38// renderedItem holds the rendered content and height of an item.
39type renderedItem struct {
40 content string
41 height int
42}
43
44// NewList creates a new lazy-loaded list.
45func NewList(items ...Item) *List {
46 l := new(List)
47 l.items = items
48 l.selectedIdx = -1
49 return l
50}
51
52// RenderCallback defines a function that can modify an item before it is
53// rendered.
54type RenderCallback func(idx, selectedIdx int, item Item) Item
55
56// RegisterRenderCallback registers a callback to be called when rendering
57// items. This can be used to modify items before they are rendered.
58func (l *List) RegisterRenderCallback(cb RenderCallback) {
59 l.renderCallbacks = append(l.renderCallbacks, cb)
60}
61
62// SetSize sets the size of the list viewport.
63func (l *List) SetSize(width, height int) {
64 l.width = width
65 l.height = height
66}
67
68// SetGap sets the gap between items.
69func (l *List) SetGap(gap int) {
70 l.gap = gap
71}
72
73// Gap returns the gap between items.
74func (l *List) Gap() int {
75 return l.gap
76}
77
78// AtBottom returns whether the list is showing the last item at the bottom.
79func (l *List) AtBottom() bool {
80 const margin = 2
81
82 if len(l.items) == 0 || l.offsetIdx >= len(l.items)-1 {
83 return true
84 }
85
86 // Calculate the height from offsetIdx to the end.
87 var totalHeight int
88 for idx := l.offsetIdx; idx < len(l.items); idx++ {
89 item := l.getItem(idx)
90 itemHeight := item.height
91 if l.gap > 0 && idx > l.offsetIdx {
92 itemHeight += l.gap
93 }
94 totalHeight += itemHeight
95 }
96
97 return totalHeight-l.offsetLine-margin <= l.height
98}
99
100// SetReverse shows the list in reverse order.
101func (l *List) SetReverse(reverse bool) {
102 l.reverse = reverse
103}
104
105// Width returns the width of the list viewport.
106func (l *List) Width() int {
107 return l.width
108}
109
110// Height returns the height of the list viewport.
111func (l *List) Height() int {
112 return l.height
113}
114
115// Len returns the number of items in the list.
116func (l *List) Len() int {
117 return len(l.items)
118}
119
120// lastOffsetItem returns the index and line offsets of the last item that can
121// be partially visible in the viewport.
122func (l *List) lastOffsetItem() (int, int, int) {
123 var totalHeight int
124 var idx int
125 for idx = len(l.items) - 1; idx >= 0; idx-- {
126 item := l.getItem(idx)
127 itemHeight := item.height
128 if l.gap > 0 && idx < len(l.items)-1 {
129 itemHeight += l.gap
130 }
131 totalHeight += itemHeight
132 if totalHeight > l.height {
133 break
134 }
135 }
136
137 // Calculate line offset within the item
138 lineOffset := max(totalHeight-l.height, 0)
139 idx = max(idx, 0)
140
141 return idx, lineOffset, totalHeight
142}
143
144// getItem renders (if needed) and returns the item at the given index.
145func (l *List) getItem(idx int) renderedItem {
146 if idx < 0 || idx >= len(l.items) {
147 return renderedItem{}
148 }
149
150 item := l.items[idx]
151 if len(l.renderCallbacks) > 0 {
152 for _, cb := range l.renderCallbacks {
153 if it := cb(idx, l.selectedIdx, item); it != nil {
154 item = it
155 }
156 }
157 }
158
159 rendered := item.Render(l.width)
160 rendered = strings.TrimRight(rendered, "\n")
161 height := strings.Count(rendered, "\n") + 1
162 ri := renderedItem{
163 content: rendered,
164 height: height,
165 }
166
167 return ri
168}
169
170// ScrollToIndex scrolls the list to the given item index.
171func (l *List) ScrollToIndex(index int) {
172 if index < 0 {
173 index = 0
174 }
175 if index >= len(l.items) {
176 index = len(l.items) - 1
177 }
178 l.offsetIdx = index
179 l.offsetLine = 0
180}
181
182// ScrollBy scrolls the list by the given number of lines.
183func (l *List) ScrollBy(lines int) {
184 if len(l.items) == 0 || lines == 0 {
185 return
186 }
187
188 if l.reverse {
189 lines = -lines
190 }
191
192 if lines > 0 {
193 if l.AtBottom() {
194 // Already at bottom
195 return
196 }
197
198 // Scroll down
199 l.offsetLine += lines
200 currentItem := l.getItem(l.offsetIdx)
201 for l.offsetLine >= currentItem.height {
202 l.offsetLine -= currentItem.height
203 if l.gap > 0 {
204 l.offsetLine = max(0, l.offsetLine-l.gap)
205 }
206
207 // Move to next item
208 l.offsetIdx++
209 if l.offsetIdx > len(l.items)-1 {
210 // Reached bottom
211 l.ScrollToBottom()
212 return
213 }
214 currentItem = l.getItem(l.offsetIdx)
215 }
216
217 lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
218 if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) {
219 // Clamp to bottom
220 l.offsetIdx = lastOffsetIdx
221 l.offsetLine = lastOffsetLine
222 }
223 } else if lines < 0 {
224 // Scroll up
225 l.offsetLine += lines // lines is negative
226 for l.offsetLine < 0 {
227 // Move to previous item
228 l.offsetIdx--
229 if l.offsetIdx < 0 {
230 // Reached top
231 l.ScrollToTop()
232 break
233 }
234 prevItem := l.getItem(l.offsetIdx)
235 totalHeight := prevItem.height
236 if l.gap > 0 {
237 totalHeight += l.gap
238 }
239 l.offsetLine += totalHeight
240 }
241 }
242}
243
244// VisibleItemIndices finds the range of items that are visible in the viewport.
245// This is used for checking if selected item is in view.
246func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
247 if len(l.items) == 0 {
248 return 0, 0
249 }
250
251 startIdx = l.offsetIdx
252 currentIdx := startIdx
253 visibleHeight := -l.offsetLine
254
255 for currentIdx < len(l.items) {
256 item := l.getItem(currentIdx)
257 visibleHeight += item.height
258 if l.gap > 0 {
259 visibleHeight += l.gap
260 }
261
262 if visibleHeight >= l.height {
263 break
264 }
265 currentIdx++
266 }
267
268 endIdx = currentIdx
269 if endIdx >= len(l.items) {
270 endIdx = len(l.items) - 1
271 }
272
273 return startIdx, endIdx
274}
275
276// Render renders the list and returns the visible lines.
277func (l *List) Render() string {
278 if len(l.items) == 0 {
279 return ""
280 }
281
282 var lines []string
283 currentIdx := l.offsetIdx
284 currentOffset := l.offsetLine
285
286 linesNeeded := l.height
287
288 for linesNeeded > 0 && currentIdx < len(l.items) {
289 item := l.getItem(currentIdx)
290 itemLines := strings.Split(item.content, "\n")
291 itemHeight := len(itemLines)
292
293 if currentOffset >= 0 && currentOffset < itemHeight {
294 // Add visible content lines
295 lines = append(lines, itemLines[currentOffset:]...)
296
297 // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
298 // But in the loop we can just add it and trim later
299 if l.gap > 0 {
300 for i := 0; i < l.gap; i++ {
301 lines = append(lines, "")
302 }
303 }
304 } else {
305 // offsetLine starts in the gap
306 gapOffset := currentOffset - itemHeight
307 gapRemaining := l.gap - gapOffset
308 if gapRemaining > 0 {
309 for range gapRemaining {
310 lines = append(lines, "")
311 }
312 }
313 }
314
315 linesNeeded = l.height - len(lines)
316 currentIdx++
317 currentOffset = 0 // Reset offset for subsequent items
318 }
319
320 l.height = max(l.height, 0)
321
322 if len(lines) > l.height {
323 lines = lines[:l.height]
324 }
325
326 if l.reverse {
327 // Reverse the lines so the list renders bottom-to-top.
328 for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
329 lines[i], lines[j] = lines[j], lines[i]
330 }
331 }
332
333 return strings.Join(lines, "\n")
334}
335
336// PrependItems prepends items to the list.
337func (l *List) PrependItems(items ...Item) {
338 l.items = append(items, l.items...)
339
340 // Keep view position relative to the content that was visible
341 l.offsetIdx += len(items)
342
343 // Update selection index if valid
344 if l.selectedIdx != -1 {
345 l.selectedIdx += len(items)
346 }
347}
348
349// SetItems sets the items in the list.
350func (l *List) SetItems(items ...Item) {
351 l.setItems(true, items...)
352}
353
354// setItems sets the items in the list. If evict is true, it clears the
355// rendered item cache.
356func (l *List) setItems(evict bool, items ...Item) {
357 l.items = items
358 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
359 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
360 l.offsetLine = 0
361}
362
363// AppendItems appends items to the list.
364func (l *List) AppendItems(items ...Item) {
365 l.items = append(l.items, items...)
366}
367
368// RemoveItem removes the item at the given index from the list.
369func (l *List) RemoveItem(idx int) {
370 if idx < 0 || idx >= len(l.items) {
371 return
372 }
373
374 // Remove the item
375 l.items = append(l.items[:idx], l.items[idx+1:]...)
376
377 // Adjust selection if needed
378 if l.selectedIdx == idx {
379 l.selectedIdx = -1
380 } else if l.selectedIdx > idx {
381 l.selectedIdx--
382 }
383
384 // Adjust offset if needed
385 if l.offsetIdx > idx {
386 l.offsetIdx--
387 } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
388 l.offsetIdx = max(0, len(l.items)-1)
389 l.offsetLine = 0
390 }
391}
392
393// Focused returns whether the list is focused.
394func (l *List) Focused() bool {
395 return l.focused
396}
397
398// Focus sets the focus state of the list.
399func (l *List) Focus() {
400 l.focused = true
401}
402
403// Blur removes the focus state from the list.
404func (l *List) Blur() {
405 l.focused = false
406}
407
408// ScrollToTop scrolls the list to the top.
409func (l *List) ScrollToTop() {
410 l.offsetIdx = 0
411 l.offsetLine = 0
412}
413
414// ScrollToBottom scrolls the list to the bottom.
415func (l *List) ScrollToBottom() {
416 if len(l.items) == 0 {
417 return
418 }
419
420 lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
421 l.offsetIdx = lastOffsetIdx
422 l.offsetLine = lastOffsetLine
423}
424
425// ScrollToSelected scrolls the list to the selected item.
426func (l *List) ScrollToSelected() {
427 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
428 return
429 }
430
431 startIdx, endIdx := l.VisibleItemIndices()
432 if l.selectedIdx < startIdx {
433 // Selected item is above the visible range
434 l.offsetIdx = l.selectedIdx
435 l.offsetLine = 0
436 } else if l.selectedIdx > endIdx {
437 // Selected item is below the visible range
438 // Scroll so that the selected item is at the bottom
439 var totalHeight int
440 for i := l.selectedIdx; i >= 0; i-- {
441 item := l.getItem(i)
442 totalHeight += item.height
443 if l.gap > 0 && i < l.selectedIdx {
444 totalHeight += l.gap
445 }
446 if totalHeight >= l.height {
447 l.offsetIdx = i
448 l.offsetLine = totalHeight - l.height
449 break
450 }
451 }
452 if totalHeight < l.height {
453 // All items fit in the viewport
454 l.ScrollToTop()
455 }
456 }
457}
458
459// SelectedItemInView returns whether the selected item is currently in view.
460func (l *List) SelectedItemInView() bool {
461 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
462 return false
463 }
464 startIdx, endIdx := l.VisibleItemIndices()
465 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
466}
467
468// SetSelected sets the selected item index in the list.
469// It returns -1 if the index is out of bounds.
470func (l *List) SetSelected(index int) {
471 if index < 0 || index >= len(l.items) {
472 l.selectedIdx = -1
473 } else {
474 l.selectedIdx = index
475 }
476}
477
478// Selected returns the index of the currently selected item. It returns -1 if
479// no item is selected.
480func (l *List) Selected() int {
481 return l.selectedIdx
482}
483
484// IsSelectedFirst returns whether the first item is selected.
485func (l *List) IsSelectedFirst() bool {
486 return l.selectedIdx == 0
487}
488
489// IsSelectedLast returns whether the last item is selected.
490func (l *List) IsSelectedLast() bool {
491 return l.selectedIdx == len(l.items)-1
492}
493
494// SelectPrev selects the visually previous item (moves toward visual top).
495// It returns whether the selection changed.
496func (l *List) SelectPrev() bool {
497 if l.reverse {
498 // In reverse, visual up = higher index
499 if l.selectedIdx < len(l.items)-1 {
500 l.selectedIdx++
501 return true
502 }
503 } else {
504 // Normal: visual up = lower index
505 if l.selectedIdx > 0 {
506 l.selectedIdx--
507 return true
508 }
509 }
510 return false
511}
512
513// SelectNext selects the next item in the list.
514// It returns whether the selection changed.
515func (l *List) SelectNext() bool {
516 if l.reverse {
517 // In reverse, visual down = lower index
518 if l.selectedIdx > 0 {
519 l.selectedIdx--
520 return true
521 }
522 } else {
523 // Normal: visual down = higher index
524 if l.selectedIdx < len(l.items)-1 {
525 l.selectedIdx++
526 return true
527 }
528 }
529 return false
530}
531
532// SelectFirst selects the first item in the list.
533// It returns whether the selection changed.
534func (l *List) SelectFirst() bool {
535 if len(l.items) == 0 {
536 return false
537 }
538 l.selectedIdx = 0
539 return true
540}
541
542// SelectLast selects the last item in the list (highest index).
543// It returns whether the selection changed.
544func (l *List) SelectLast() bool {
545 if len(l.items) == 0 {
546 return false
547 }
548 l.selectedIdx = len(l.items) - 1
549 return true
550}
551
552// WrapToStart wraps selection to the visual start (for circular navigation).
553// In normal mode, this is index 0. In reverse mode, this is the highest index.
554func (l *List) WrapToStart() bool {
555 if len(l.items) == 0 {
556 return false
557 }
558 if l.reverse {
559 l.selectedIdx = len(l.items) - 1
560 } else {
561 l.selectedIdx = 0
562 }
563 return true
564}
565
566// WrapToEnd wraps selection to the visual end (for circular navigation).
567// In normal mode, this is the highest index. In reverse mode, this is index 0.
568func (l *List) WrapToEnd() bool {
569 if len(l.items) == 0 {
570 return false
571 }
572 if l.reverse {
573 l.selectedIdx = 0
574 } else {
575 l.selectedIdx = len(l.items) - 1
576 }
577 return true
578}
579
580// SelectedItem returns the currently selected item. It may be nil if no item
581// is selected.
582func (l *List) SelectedItem() Item {
583 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
584 return nil
585 }
586 return l.items[l.selectedIdx]
587}
588
589// SelectFirstInView selects the first item currently in view.
590func (l *List) SelectFirstInView() {
591 startIdx, _ := l.VisibleItemIndices()
592 l.selectedIdx = startIdx
593}
594
595// SelectLastInView selects the last item currently in view.
596func (l *List) SelectLastInView() {
597 _, endIdx := l.VisibleItemIndices()
598 l.selectedIdx = endIdx
599}
600
601// ItemAt returns the item at the given index.
602func (l *List) ItemAt(index int) Item {
603 if index < 0 || index >= len(l.items) {
604 return nil
605 }
606 return l.items[index]
607}
608
609// ItemIndexAtPosition returns the item at the given viewport-relative y
610// coordinate. Returns the item index and the y offset within that item. It
611// returns -1, -1 if no item is found.
612func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
613 return l.findItemAtY(x, y)
614}
615
616// findItemAtY finds the item at the given viewport y coordinate.
617// Returns the item index and the y offset within that item. It returns -1, -1
618// if no item is found.
619func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
620 if y < 0 || y >= l.height {
621 return -1, -1
622 }
623
624 // Walk through visible items to find which one contains this y
625 currentIdx := l.offsetIdx
626 currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
627
628 for currentIdx < len(l.items) && currentLine < l.height {
629 item := l.getItem(currentIdx)
630 itemEndLine := currentLine + item.height
631
632 // Check if y is within this item's visible range
633 if y >= currentLine && y < itemEndLine {
634 // Found the item, calculate itemY (offset within the item)
635 itemY = y - currentLine
636 return currentIdx, itemY
637 }
638
639 // Move to next item
640 currentLine = itemEndLine
641 if l.gap > 0 {
642 currentLine += l.gap
643 }
644 currentIdx++
645 }
646
647 return -1, -1
648}