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