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