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 {
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 := countLines(rendered)
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 // Scroll down
194 l.offsetLine += lines
195 currentItem := l.getItem(l.offsetIdx)
196 for l.offsetLine >= currentItem.height {
197 l.offsetLine -= currentItem.height
198 if l.gap > 0 {
199 l.offsetLine -= l.gap
200 }
201
202 // Move to next item
203 l.offsetIdx++
204 if l.offsetIdx > len(l.items)-1 {
205 // Reached bottom
206 l.ScrollToBottom()
207 return
208 }
209 currentItem = l.getItem(l.offsetIdx)
210 }
211
212 lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
213 if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) {
214 // Clamp to bottom
215 l.offsetIdx = lastOffsetIdx
216 l.offsetLine = lastOffsetLine
217 }
218 } else if lines < 0 {
219 // Scroll up
220 l.offsetLine += lines // lines is negative
221 for l.offsetLine < 0 {
222 if l.offsetIdx <= 0 {
223 // Reached top
224 l.ScrollToTop()
225 break
226 }
227
228 // Move to previous item
229 l.offsetIdx--
230 prevItem := l.getItem(l.offsetIdx)
231 totalHeight := prevItem.height
232 if l.gap > 0 {
233 totalHeight += l.gap
234 }
235 l.offsetLine += totalHeight
236 }
237 }
238}
239
240// VisibleItemIndices finds the range of items that are visible in the viewport.
241// This is used for checking if selected item is in view.
242func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
243 if len(l.items) == 0 {
244 return 0, 0
245 }
246
247 startIdx = l.offsetIdx
248 currentIdx := startIdx
249 visibleHeight := -l.offsetLine
250
251 for currentIdx < len(l.items) {
252 item := l.getItem(currentIdx)
253 visibleHeight += item.height
254 if l.gap > 0 {
255 visibleHeight += l.gap
256 }
257
258 if visibleHeight >= l.height {
259 break
260 }
261 currentIdx++
262 }
263
264 endIdx = currentIdx
265 if endIdx >= len(l.items) {
266 endIdx = len(l.items) - 1
267 }
268
269 return startIdx, endIdx
270}
271
272// Render renders the list and returns the visible lines.
273func (l *List) Render() string {
274 if len(l.items) == 0 {
275 return ""
276 }
277
278 var lines []string
279 currentIdx := l.offsetIdx
280 currentOffset := l.offsetLine
281
282 linesNeeded := l.height
283
284 for linesNeeded > 0 && currentIdx < len(l.items) {
285 item := l.getItem(currentIdx)
286 itemLines := strings.Split(item.content, "\n")
287 itemHeight := len(itemLines)
288
289 if currentOffset >= 0 && currentOffset < itemHeight {
290 // Add visible content lines
291 lines = append(lines, itemLines[currentOffset:]...)
292
293 // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
294 // But in the loop we can just add it and trim later
295 if l.gap > 0 {
296 for i := 0; i < l.gap; i++ {
297 lines = append(lines, "")
298 }
299 }
300 } else {
301 // offsetLine starts in the gap
302 gapOffset := currentOffset - itemHeight
303 gapRemaining := l.gap - gapOffset
304 if gapRemaining > 0 {
305 for range gapRemaining {
306 lines = append(lines, "")
307 }
308 }
309 }
310
311 linesNeeded = l.height - len(lines)
312 currentIdx++
313 currentOffset = 0 // Reset offset for subsequent items
314 }
315
316 l.height = max(l.height, 0)
317
318 if len(lines) > l.height {
319 lines = lines[:l.height]
320 }
321
322 if l.reverse {
323 // Reverse the lines so the list renders bottom-to-top.
324 for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
325 lines[i], lines[j] = lines[j], lines[i]
326 }
327 }
328
329 return strings.Join(lines, "\n")
330}
331
332// PrependItems prepends items to the list.
333func (l *List) PrependItems(items ...Item) {
334 l.items = append(items, l.items...)
335
336 // Keep view position relative to the content that was visible
337 l.offsetIdx += len(items)
338
339 // Update selection index if valid
340 if l.selectedIdx != -1 {
341 l.selectedIdx += len(items)
342 }
343}
344
345// SetItems sets the items in the list.
346func (l *List) SetItems(items ...Item) {
347 l.setItems(true, items...)
348}
349
350// setItems sets the items in the list. If evict is true, it clears the
351// rendered item cache.
352func (l *List) setItems(evict bool, items ...Item) {
353 l.items = items
354 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
355 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
356 l.offsetLine = 0
357}
358
359// AppendItems appends items to the list.
360func (l *List) AppendItems(items ...Item) {
361 l.items = append(l.items, items...)
362}
363
364// RemoveItem removes the item at the given index from the list.
365func (l *List) RemoveItem(idx int) {
366 if idx < 0 || idx >= len(l.items) {
367 return
368 }
369
370 // Remove the item
371 l.items = append(l.items[:idx], l.items[idx+1:]...)
372
373 // Adjust selection if needed
374 if l.selectedIdx == idx {
375 l.selectedIdx = -1
376 } else if l.selectedIdx > idx {
377 l.selectedIdx--
378 }
379
380 // Adjust offset if needed
381 if l.offsetIdx > idx {
382 l.offsetIdx--
383 } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
384 l.offsetIdx = max(0, len(l.items)-1)
385 l.offsetLine = 0
386 }
387}
388
389// Focused returns whether the list is focused.
390func (l *List) Focused() bool {
391 return l.focused
392}
393
394// Focus sets the focus state of the list.
395func (l *List) Focus() {
396 l.focused = true
397}
398
399// Blur removes the focus state from the list.
400func (l *List) Blur() {
401 l.focused = false
402}
403
404// ScrollToTop scrolls the list to the top.
405func (l *List) ScrollToTop() {
406 l.offsetIdx = 0
407 l.offsetLine = 0
408}
409
410// ScrollToBottom scrolls the list to the bottom.
411func (l *List) ScrollToBottom() {
412 if len(l.items) == 0 {
413 return
414 }
415
416 lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
417 l.offsetIdx = lastOffsetIdx
418 l.offsetLine = lastOffsetLine
419}
420
421// ScrollToSelected scrolls the list to the selected item.
422func (l *List) ScrollToSelected() {
423 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
424 return
425 }
426
427 startIdx, endIdx := l.VisibleItemIndices()
428 if l.selectedIdx < startIdx {
429 // Selected item is above the visible range
430 l.offsetIdx = l.selectedIdx
431 l.offsetLine = 0
432 } else if l.selectedIdx > endIdx {
433 // Selected item is below the visible range
434 // Scroll so that the selected item is at the bottom
435 var totalHeight int
436 for i := l.selectedIdx; i >= 0; i-- {
437 item := l.getItem(i)
438 totalHeight += item.height
439 if l.gap > 0 && i < l.selectedIdx {
440 totalHeight += l.gap
441 }
442 if totalHeight >= l.height {
443 l.offsetIdx = i
444 l.offsetLine = totalHeight - l.height
445 break
446 }
447 }
448 if totalHeight < l.height {
449 // All items fit in the viewport
450 l.ScrollToTop()
451 }
452 }
453}
454
455// SelectedItemInView returns whether the selected item is currently in view.
456func (l *List) SelectedItemInView() bool {
457 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
458 return false
459 }
460 startIdx, endIdx := l.VisibleItemIndices()
461 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
462}
463
464// SetSelected sets the selected item index in the list.
465// It returns -1 if the index is out of bounds.
466func (l *List) SetSelected(index int) {
467 if index < 0 || index >= len(l.items) {
468 l.selectedIdx = -1
469 } else {
470 l.selectedIdx = index
471 }
472}
473
474// Selected returns the index of the currently selected item. It returns -1 if
475// no item is selected.
476func (l *List) Selected() int {
477 return l.selectedIdx
478}
479
480// IsSelectedFirst returns whether the first item is selected.
481func (l *List) IsSelectedFirst() bool {
482 return l.selectedIdx == 0
483}
484
485// IsSelectedLast returns whether the last item is selected.
486func (l *List) IsSelectedLast() bool {
487 return l.selectedIdx == len(l.items)-1
488}
489
490// SelectPrev selects the visually previous item (moves toward visual top).
491// It returns whether the selection changed.
492func (l *List) SelectPrev() bool {
493 if l.reverse {
494 // In reverse, visual up = higher index
495 if l.selectedIdx < len(l.items)-1 {
496 l.selectedIdx++
497 return true
498 }
499 } else {
500 // Normal: visual up = lower index
501 if l.selectedIdx > 0 {
502 l.selectedIdx--
503 return true
504 }
505 }
506 return false
507}
508
509// SelectNext selects the next item in the list.
510// It returns whether the selection changed.
511func (l *List) SelectNext() bool {
512 if l.reverse {
513 // In reverse, visual down = lower index
514 if l.selectedIdx > 0 {
515 l.selectedIdx--
516 return true
517 }
518 } else {
519 // Normal: visual down = higher index
520 if l.selectedIdx < len(l.items)-1 {
521 l.selectedIdx++
522 return true
523 }
524 }
525 return false
526}
527
528// SelectFirst selects the first item in the list.
529// It returns whether the selection changed.
530func (l *List) SelectFirst() bool {
531 if len(l.items) == 0 {
532 return false
533 }
534 l.selectedIdx = 0
535 return true
536}
537
538// SelectLast selects the last item in the list (highest index).
539// It returns whether the selection changed.
540func (l *List) SelectLast() bool {
541 if len(l.items) == 0 {
542 return false
543 }
544 l.selectedIdx = len(l.items) - 1
545 return true
546}
547
548// WrapToStart wraps selection to the visual start (for circular navigation).
549// In normal mode, this is index 0. In reverse mode, this is the highest index.
550func (l *List) WrapToStart() bool {
551 if len(l.items) == 0 {
552 return false
553 }
554 if l.reverse {
555 l.selectedIdx = len(l.items) - 1
556 } else {
557 l.selectedIdx = 0
558 }
559 return true
560}
561
562// WrapToEnd wraps selection to the visual end (for circular navigation).
563// In normal mode, this is the highest index. In reverse mode, this is index 0.
564func (l *List) WrapToEnd() bool {
565 if len(l.items) == 0 {
566 return false
567 }
568 if l.reverse {
569 l.selectedIdx = 0
570 } else {
571 l.selectedIdx = len(l.items) - 1
572 }
573 return true
574}
575
576// SelectedItem returns the currently selected item. It may be nil if no item
577// is selected.
578func (l *List) SelectedItem() Item {
579 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
580 return nil
581 }
582 return l.items[l.selectedIdx]
583}
584
585// SelectFirstInView selects the first item currently in view.
586func (l *List) SelectFirstInView() {
587 startIdx, _ := l.VisibleItemIndices()
588 l.selectedIdx = startIdx
589}
590
591// SelectLastInView selects the last item currently in view.
592func (l *List) SelectLastInView() {
593 _, endIdx := l.VisibleItemIndices()
594 l.selectedIdx = endIdx
595}
596
597// ItemAt returns the item at the given index.
598func (l *List) ItemAt(index int) Item {
599 if index < 0 || index >= len(l.items) {
600 return nil
601 }
602 return l.items[index]
603}
604
605// ItemIndexAtPosition returns the item at the given viewport-relative y
606// coordinate. Returns the item index and the y offset within that item. It
607// returns -1, -1 if no item is found.
608func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
609 return l.findItemAtY(x, y)
610}
611
612// findItemAtY finds the item at the given viewport y coordinate.
613// Returns the item index and the y offset within that item. It returns -1, -1
614// if no item is found.
615func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
616 if y < 0 || y >= l.height {
617 return -1, -1
618 }
619
620 // Walk through visible items to find which one contains this y
621 currentIdx := l.offsetIdx
622 currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
623
624 for currentIdx < len(l.items) && currentLine < l.height {
625 item := l.getItem(currentIdx)
626 itemEndLine := currentLine + item.height
627
628 // Check if y is within this item's visible range
629 if y >= currentLine && y < itemEndLine {
630 // Found the item, calculate itemY (offset within the item)
631 itemY = y - currentLine
632 return currentIdx, itemY
633 }
634
635 // Move to next item
636 currentLine = itemEndLine
637 if l.gap > 0 {
638 currentLine += l.gap
639 }
640 currentIdx++
641 }
642
643 return -1, -1
644}
645
646// countLines counts the number of lines in a string.
647func countLines(s string) int {
648 if s == "" {
649 return 1
650 }
651 return strings.Count(s, "\n") + 1
652}