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