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