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