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 if len(lines) > l.height {
312 lines = lines[:l.height]
313 }
314
315 if l.reverse {
316 // Reverse the lines so the list renders bottom-to-top.
317 for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
318 lines[i], lines[j] = lines[j], lines[i]
319 }
320 }
321
322 return strings.Join(lines, "\n")
323}
324
325// PrependItems prepends items to the list.
326func (l *List) PrependItems(items ...Item) {
327 l.items = append(items, l.items...)
328
329 // Keep view position relative to the content that was visible
330 l.offsetIdx += len(items)
331
332 // Update selection index if valid
333 if l.selectedIdx != -1 {
334 l.selectedIdx += len(items)
335 }
336}
337
338// SetItems sets the items in the list.
339func (l *List) SetItems(items ...Item) {
340 l.setItems(true, items...)
341}
342
343// setItems sets the items in the list. If evict is true, it clears the
344// rendered item cache.
345func (l *List) setItems(evict bool, items ...Item) {
346 l.items = items
347 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
348 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
349 l.offsetLine = 0
350}
351
352// AppendItems appends items to the list.
353func (l *List) AppendItems(items ...Item) {
354 l.items = append(l.items, items...)
355}
356
357// RemoveItem removes the item at the given index from the list.
358func (l *List) RemoveItem(idx int) {
359 if idx < 0 || idx >= len(l.items) {
360 return
361 }
362
363 // Remove the item
364 l.items = append(l.items[:idx], l.items[idx+1:]...)
365
366 // Adjust selection if needed
367 if l.selectedIdx == idx {
368 l.selectedIdx = -1
369 } else if l.selectedIdx > idx {
370 l.selectedIdx--
371 }
372
373 // Adjust offset if needed
374 if l.offsetIdx > idx {
375 l.offsetIdx--
376 } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
377 l.offsetIdx = max(0, len(l.items)-1)
378 l.offsetLine = 0
379 }
380}
381
382// Focused returns whether the list is focused.
383func (l *List) Focused() bool {
384 return l.focused
385}
386
387// Focus sets the focus state of the list.
388func (l *List) Focus() {
389 l.focused = true
390}
391
392// Blur removes the focus state from the list.
393func (l *List) Blur() {
394 l.focused = false
395}
396
397// ScrollToTop scrolls the list to the top.
398func (l *List) ScrollToTop() {
399 l.offsetIdx = 0
400 l.offsetLine = 0
401}
402
403// ScrollToBottom scrolls the list to the bottom.
404func (l *List) ScrollToBottom() {
405 if len(l.items) == 0 {
406 return
407 }
408
409 // Scroll to the last item
410 var totalHeight int
411 for i := len(l.items) - 1; i >= 0; i-- {
412 item := l.getItem(i)
413 totalHeight += item.height
414 if l.gap > 0 && i < len(l.items)-1 {
415 totalHeight += l.gap
416 }
417 if totalHeight >= l.height {
418 l.offsetIdx = i
419 l.offsetLine = totalHeight - l.height
420 break
421 }
422 }
423 if totalHeight < l.height {
424 // All items fit in the viewport
425 l.ScrollToTop()
426 }
427}
428
429// ScrollToSelected scrolls the list to the selected item.
430func (l *List) ScrollToSelected() {
431 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
432 return
433 }
434
435 startIdx, endIdx := l.VisibleItemIndices()
436 if l.selectedIdx < startIdx {
437 // Selected item is above the visible range
438 l.offsetIdx = l.selectedIdx
439 l.offsetLine = 0
440 } else if l.selectedIdx > endIdx {
441 // Selected item is below the visible range
442 // Scroll so that the selected item is at the bottom
443 var totalHeight int
444 for i := l.selectedIdx; i >= 0; i-- {
445 item := l.getItem(i)
446 totalHeight += item.height
447 if l.gap > 0 && i < l.selectedIdx {
448 totalHeight += l.gap
449 }
450 if totalHeight >= l.height {
451 l.offsetIdx = i
452 l.offsetLine = totalHeight - l.height
453 break
454 }
455 }
456 if totalHeight < l.height {
457 // All items fit in the viewport
458 l.ScrollToTop()
459 }
460 }
461}
462
463// SelectedItemInView returns whether the selected item is currently in view.
464func (l *List) SelectedItemInView() bool {
465 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
466 return false
467 }
468 startIdx, endIdx := l.VisibleItemIndices()
469 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
470}
471
472// SetSelected sets the selected item index in the list.
473// It returns -1 if the index is out of bounds.
474func (l *List) SetSelected(index int) {
475 if index < 0 || index >= len(l.items) {
476 l.selectedIdx = -1
477 } else {
478 l.selectedIdx = index
479 }
480}
481
482// Selected returns the index of the currently selected item. It returns -1 if
483// no item is selected.
484func (l *List) Selected() int {
485 return l.selectedIdx
486}
487
488// IsSelectedFirst returns whether the first item is selected.
489func (l *List) IsSelectedFirst() bool {
490 return l.selectedIdx == 0
491}
492
493// IsSelectedLast returns whether the last item is selected.
494func (l *List) IsSelectedLast() bool {
495 return l.selectedIdx == len(l.items)-1
496}
497
498// SelectPrev selects the visually previous item (moves toward visual top).
499// It returns whether the selection changed.
500func (l *List) SelectPrev() bool {
501 if l.reverse {
502 // In reverse, visual up = higher index
503 if l.selectedIdx < len(l.items)-1 {
504 l.selectedIdx++
505 return true
506 }
507 } else {
508 // Normal: visual up = lower index
509 if l.selectedIdx > 0 {
510 l.selectedIdx--
511 return true
512 }
513 }
514 return false
515}
516
517// SelectNext selects the next item in the list.
518// It returns whether the selection changed.
519func (l *List) SelectNext() bool {
520 if l.reverse {
521 // In reverse, visual down = lower index
522 if l.selectedIdx > 0 {
523 l.selectedIdx--
524 return true
525 }
526 } else {
527 // Normal: visual down = higher index
528 if l.selectedIdx < len(l.items)-1 {
529 l.selectedIdx++
530 return true
531 }
532 }
533 return false
534}
535
536// SelectFirst selects the first item in the list.
537// It returns whether the selection changed.
538func (l *List) SelectFirst() bool {
539 if len(l.items) == 0 {
540 return false
541 }
542 l.selectedIdx = 0
543 return true
544}
545
546// SelectLast selects the last item in the list (highest index).
547// It returns whether the selection changed.
548func (l *List) SelectLast() bool {
549 if len(l.items) == 0 {
550 return false
551 }
552 l.selectedIdx = len(l.items) - 1
553 return true
554}
555
556// WrapToStart wraps selection to the visual start (for circular navigation).
557// In normal mode, this is index 0. In reverse mode, this is the highest index.
558func (l *List) WrapToStart() bool {
559 if len(l.items) == 0 {
560 return false
561 }
562 if l.reverse {
563 l.selectedIdx = len(l.items) - 1
564 } else {
565 l.selectedIdx = 0
566 }
567 return true
568}
569
570// WrapToEnd wraps selection to the visual end (for circular navigation).
571// In normal mode, this is the highest index. In reverse mode, this is index 0.
572func (l *List) WrapToEnd() bool {
573 if len(l.items) == 0 {
574 return false
575 }
576 if l.reverse {
577 l.selectedIdx = 0
578 } else {
579 l.selectedIdx = len(l.items) - 1
580 }
581 return true
582}
583
584// SelectedItem returns the currently selected item. It may be nil if no item
585// is selected.
586func (l *List) SelectedItem() Item {
587 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
588 return nil
589 }
590 return l.items[l.selectedIdx]
591}
592
593// SelectFirstInView selects the first item currently in view.
594func (l *List) SelectFirstInView() {
595 startIdx, _ := l.VisibleItemIndices()
596 l.selectedIdx = startIdx
597}
598
599// SelectLastInView selects the last item currently in view.
600func (l *List) SelectLastInView() {
601 _, endIdx := l.VisibleItemIndices()
602 l.selectedIdx = endIdx
603}
604
605// ItemAt returns the item at the given index.
606func (l *List) ItemAt(index int) Item {
607 if index < 0 || index >= len(l.items) {
608 return nil
609 }
610 return l.items[index]
611}
612
613// ItemIndexAtPosition returns the item at the given viewport-relative y
614// coordinate. Returns the item index and the y offset within that item. It
615// returns -1, -1 if no item is found.
616func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
617 return l.findItemAtY(x, y)
618}
619
620// findItemAtY finds the item at the given viewport y coordinate.
621// Returns the item index and the y offset within that item. It returns -1, -1
622// if no item is found.
623func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
624 if y < 0 || y >= l.height {
625 return -1, -1
626 }
627
628 // Walk through visible items to find which one contains this y
629 currentIdx := l.offsetIdx
630 currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
631
632 for currentIdx < len(l.items) && currentLine < l.height {
633 item := l.getItem(currentIdx)
634 itemEndLine := currentLine + item.height
635
636 // Check if y is within this item's visible range
637 if y >= currentLine && y < itemEndLine {
638 // Found the item, calculate itemY (offset within the item)
639 itemY = y - currentLine
640 return currentIdx, itemY
641 }
642
643 // Move to next item
644 currentLine = itemEndLine
645 if l.gap > 0 {
646 currentLine += l.gap
647 }
648 currentIdx++
649 }
650
651 return -1, -1
652}
653
654// countLines counts the number of lines in a string.
655func countLines(s string) int {
656 if s == "" {
657 return 1
658 }
659 return strings.Count(s, "\n") + 1
660}