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 // Focus and selection state
21 focused bool
22 selectedIdx int // The current selected index -1 means no selection
23
24 // offsetIdx is the index of the first visible item in the viewport.
25 offsetIdx int
26 // offsetLine is the number of lines of the item at offsetIdx that are
27 // scrolled out of view (above the viewport).
28 // It must always be >= 0.
29 offsetLine int
30
31 // renderCallbacks is a list of callbacks to apply when rendering items.
32 renderCallbacks []func(idx, selectedIdx int, item Item) Item
33}
34
35// renderedItem holds the rendered content and height of an item.
36type renderedItem struct {
37 content string
38 height int
39}
40
41// NewList creates a new lazy-loaded list.
42func NewList(items ...Item) *List {
43 l := new(List)
44 l.items = items
45 l.selectedIdx = -1
46 return l
47}
48
49// RegisterRenderCallback registers a callback to be called when rendering
50// items. This can be used to modify items before they are rendered.
51func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) {
52 l.renderCallbacks = append(l.renderCallbacks, cb)
53}
54
55// SetSize sets the size of the list viewport.
56func (l *List) SetSize(width, height int) {
57 l.width = width
58 l.height = height
59}
60
61// SetGap sets the gap between items.
62func (l *List) SetGap(gap int) {
63 l.gap = gap
64}
65
66// Width returns the width of the list viewport.
67func (l *List) Width() int {
68 return l.width
69}
70
71// Height returns the height of the list viewport.
72func (l *List) Height() int {
73 return l.height
74}
75
76// Len returns the number of items in the list.
77func (l *List) Len() int {
78 return len(l.items)
79}
80
81// getItem renders (if needed) and returns the item at the given index.
82func (l *List) getItem(idx int) renderedItem {
83 if idx < 0 || idx >= len(l.items) {
84 return renderedItem{}
85 }
86
87 item := l.items[idx]
88 if len(l.renderCallbacks) > 0 {
89 for _, cb := range l.renderCallbacks {
90 if it := cb(idx, l.selectedIdx, item); it != nil {
91 item = it
92 }
93 }
94 }
95
96 if focusable, isFocusable := item.(Focusable); isFocusable {
97 focusable.SetFocused(l.focused && idx == l.selectedIdx)
98 }
99
100 rendered := item.Render(l.width)
101 rendered = strings.TrimRight(rendered, "\n")
102 height := countLines(rendered)
103 ri := renderedItem{
104 content: rendered,
105 height: height,
106 }
107
108 return ri
109}
110
111// ScrollToIndex scrolls the list to the given item index.
112func (l *List) ScrollToIndex(index int) {
113 if index < 0 {
114 index = 0
115 }
116 if index >= len(l.items) {
117 index = len(l.items) - 1
118 }
119 l.offsetIdx = index
120 l.offsetLine = 0
121}
122
123// ScrollBy scrolls the list by the given number of lines.
124func (l *List) ScrollBy(lines int) {
125 if len(l.items) == 0 || lines == 0 {
126 return
127 }
128
129 if lines > 0 {
130 // Scroll down
131 // Calculate from the bottom how many lines needed to anchor the last
132 // item to the bottom
133 var totalLines int
134 var lastItemIdx int // the last item that can be partially visible
135 for i := len(l.items) - 1; i >= 0; i-- {
136 item := l.getItem(i)
137 totalLines += item.height
138 if l.gap > 0 && i < len(l.items)-1 {
139 totalLines += l.gap
140 }
141 if totalLines > l.height-1 {
142 lastItemIdx = i
143 break
144 }
145 }
146
147 // Now scroll down by lines
148 var item renderedItem
149 l.offsetLine += lines
150 for {
151 item = l.getItem(l.offsetIdx)
152 totalHeight := item.height
153 if l.gap > 0 {
154 totalHeight += l.gap
155 }
156
157 if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
158 // Valid offset
159 break
160 }
161
162 // Move to next item
163 l.offsetLine -= totalHeight
164 l.offsetIdx++
165 }
166
167 if l.offsetLine >= item.height {
168 l.offsetLine = item.height
169 }
170 } else if lines < 0 {
171 // Scroll up
172 l.offsetLine += lines // lines is negative
173 for l.offsetLine < 0 {
174 if l.offsetIdx <= 0 {
175 // Reached top
176 l.ScrollToTop()
177 break
178 }
179
180 // Move to previous item
181 l.offsetIdx--
182 prevItem := l.getItem(l.offsetIdx)
183 totalHeight := prevItem.height
184 if l.gap > 0 {
185 totalHeight += l.gap
186 }
187 l.offsetLine += totalHeight
188 }
189 }
190}
191
192// findVisibleItems finds the range of items that are visible in the viewport.
193// This is used for checking if selected item is in view.
194func (l *List) findVisibleItems() (startIdx, endIdx int) {
195 if len(l.items) == 0 {
196 return 0, 0
197 }
198
199 startIdx = l.offsetIdx
200 currentIdx := startIdx
201 visibleHeight := -l.offsetLine
202
203 for currentIdx < len(l.items) {
204 item := l.getItem(currentIdx)
205 visibleHeight += item.height
206 if l.gap > 0 {
207 visibleHeight += l.gap
208 }
209
210 if visibleHeight >= l.height {
211 break
212 }
213 currentIdx++
214 }
215
216 endIdx = currentIdx
217 if endIdx >= len(l.items) {
218 endIdx = len(l.items) - 1
219 }
220
221 return startIdx, endIdx
222}
223
224// Render renders the list and returns the visible lines.
225func (l *List) Render() string {
226 if len(l.items) == 0 {
227 return ""
228 }
229
230 var lines []string
231 currentIdx := l.offsetIdx
232 currentOffset := l.offsetLine
233
234 linesNeeded := l.height
235
236 for linesNeeded > 0 && currentIdx < len(l.items) {
237 item := l.getItem(currentIdx)
238 itemLines := strings.Split(item.content, "\n")
239 itemHeight := len(itemLines)
240
241 if currentOffset >= 0 && currentOffset < itemHeight {
242 // Add visible content lines
243 lines = append(lines, itemLines[currentOffset:]...)
244
245 // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
246 // But in the loop we can just add it and trim later
247 if l.gap > 0 {
248 for i := 0; i < l.gap; i++ {
249 lines = append(lines, "")
250 }
251 }
252 } else {
253 // offsetLine starts in the gap
254 gapOffset := currentOffset - itemHeight
255 gapRemaining := l.gap - gapOffset
256 if gapRemaining > 0 {
257 for range gapRemaining {
258 lines = append(lines, "")
259 }
260 }
261 }
262
263 linesNeeded = l.height - len(lines)
264 currentIdx++
265 currentOffset = 0 // Reset offset for subsequent items
266 }
267
268 if len(lines) > l.height {
269 lines = lines[:l.height]
270 }
271
272 return strings.Join(lines, "\n")
273}
274
275// PrependItems prepends items to the list.
276func (l *List) PrependItems(items ...Item) {
277 l.items = append(items, l.items...)
278
279 // Keep view position relative to the content that was visible
280 l.offsetIdx += len(items)
281
282 // Update selection index if valid
283 if l.selectedIdx != -1 {
284 l.selectedIdx += len(items)
285 }
286}
287
288// SetItems sets the items in the list.
289func (l *List) SetItems(items ...Item) {
290 l.setItems(true, items...)
291}
292
293// setItems sets the items in the list. If evict is true, it clears the
294// rendered item cache.
295func (l *List) setItems(evict bool, items ...Item) {
296 l.items = items
297 l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
298 l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
299 l.offsetLine = 0
300}
301
302// AppendItems appends items to the list.
303func (l *List) AppendItems(items ...Item) {
304 l.items = append(l.items, items...)
305}
306
307// Focus sets the focus state of the list.
308func (l *List) Focus() {
309 l.focused = true
310}
311
312// Blur removes the focus state from the list.
313func (l *List) Blur() {
314 l.focused = false
315}
316
317// ScrollToTop scrolls the list to the top.
318func (l *List) ScrollToTop() {
319 l.offsetIdx = 0
320 l.offsetLine = 0
321}
322
323// ScrollToBottom scrolls the list to the bottom.
324func (l *List) ScrollToBottom() {
325 if len(l.items) == 0 {
326 return
327 }
328
329 // Scroll to the last item
330 var totalHeight int
331 for i := len(l.items) - 1; i >= 0; i-- {
332 item := l.getItem(i)
333 totalHeight += item.height
334 if l.gap > 0 && i < len(l.items)-1 {
335 totalHeight += l.gap
336 }
337 if totalHeight >= l.height {
338 l.offsetIdx = i
339 l.offsetLine = totalHeight - l.height
340 break
341 }
342 }
343 if totalHeight < l.height {
344 // All items fit in the viewport
345 l.ScrollToTop()
346 }
347}
348
349// ScrollToSelected scrolls the list to the selected item.
350func (l *List) ScrollToSelected() {
351 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
352 return
353 }
354
355 startIdx, endIdx := l.findVisibleItems()
356 if l.selectedIdx < startIdx {
357 // Selected item is above the visible range
358 l.offsetIdx = l.selectedIdx
359 l.offsetLine = 0
360 } else if l.selectedIdx > endIdx {
361 // Selected item is below the visible range
362 // Scroll so that the selected item is at the bottom
363 var totalHeight int
364 for i := l.selectedIdx; i >= 0; i-- {
365 item := l.getItem(i)
366 totalHeight += item.height
367 if l.gap > 0 && i < l.selectedIdx {
368 totalHeight += l.gap
369 }
370 if totalHeight >= l.height {
371 l.offsetIdx = i
372 l.offsetLine = totalHeight - l.height
373 break
374 }
375 }
376 if totalHeight < l.height {
377 // All items fit in the viewport
378 l.ScrollToTop()
379 }
380 }
381}
382
383// SelectedItemInView returns whether the selected item is currently in view.
384func (l *List) SelectedItemInView() bool {
385 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
386 return false
387 }
388 startIdx, endIdx := l.findVisibleItems()
389 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
390}
391
392// SetSelected sets the selected item index in the list.
393func (l *List) SetSelected(index int) {
394 if index < 0 || index >= len(l.items) {
395 l.selectedIdx = -1
396 } else {
397 l.selectedIdx = index
398 }
399}
400
401// SelectPrev selects the previous item in the list.
402func (l *List) SelectPrev() {
403 if l.selectedIdx > 0 {
404 l.selectedIdx--
405 }
406}
407
408// SelectNext selects the next item in the list.
409func (l *List) SelectNext() {
410 if l.selectedIdx < len(l.items)-1 {
411 l.selectedIdx++
412 }
413}
414
415// SelectFirst selects the first item in the list.
416func (l *List) SelectFirst() {
417 if len(l.items) > 0 {
418 l.selectedIdx = 0
419 }
420}
421
422// SelectLast selects the last item in the list.
423func (l *List) SelectLast() {
424 if len(l.items) > 0 {
425 l.selectedIdx = len(l.items) - 1
426 }
427}
428
429// SelectedItem returns the currently selected item. It may be nil if no item
430// is selected.
431func (l *List) SelectedItem() Item {
432 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
433 return nil
434 }
435 return l.items[l.selectedIdx]
436}
437
438// SelectFirstInView selects the first item currently in view.
439func (l *List) SelectFirstInView() {
440 startIdx, _ := l.findVisibleItems()
441 l.selectedIdx = startIdx
442}
443
444// SelectLastInView selects the last item currently in view.
445func (l *List) SelectLastInView() {
446 _, endIdx := l.findVisibleItems()
447 l.selectedIdx = endIdx
448}
449
450// ItemAt returns the item at the given index.
451func (l *List) ItemAt(index int) Item {
452 if index < 0 || index >= len(l.items) {
453 return nil
454 }
455 return l.items[index]
456}
457
458// ItemIndexAtPosition returns the item at the given viewport-relative y
459// coordinate. Returns the item index and the y offset within that item. It
460// returns -1, -1 if no item is found.
461func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
462 return l.findItemAtY(x, y)
463}
464
465// findItemAtY finds the item at the given viewport y coordinate.
466// Returns the item index and the y offset within that item. It returns -1, -1
467// if no item is found.
468func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
469 if y < 0 || y >= l.height {
470 return -1, -1
471 }
472
473 // Walk through visible items to find which one contains this y
474 currentIdx := l.offsetIdx
475 currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
476
477 for currentIdx < len(l.items) && currentLine < l.height {
478 item := l.getItem(currentIdx)
479 itemEndLine := currentLine + item.height
480
481 // Check if y is within this item's visible range
482 if y >= currentLine && y < itemEndLine {
483 // Found the item, calculate itemY (offset within the item)
484 itemY = y - currentLine
485 return currentIdx, itemY
486 }
487
488 // Move to next item
489 currentLine = itemEndLine
490 if l.gap > 0 {
491 currentLine += l.gap
492 }
493 currentIdx++
494 }
495
496 return -1, -1
497}
498
499// countLines counts the number of lines in a string.
500func countLines(s string) int {
501 if s == "" {
502 return 0
503 }
504 return strings.Count(s, "\n") + 1
505}