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