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// VisibleItemIndices 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) VisibleItemIndices() (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// RemoveItem removes the item at the given index from the list.
308func (l *List) RemoveItem(idx int) {
309 if idx < 0 || idx >= len(l.items) {
310 return
311 }
312
313 // Remove the item
314 l.items = append(l.items[:idx], l.items[idx+1:]...)
315
316 // Adjust selection if needed
317 if l.selectedIdx == idx {
318 l.selectedIdx = -1
319 } else if l.selectedIdx > idx {
320 l.selectedIdx--
321 }
322
323 // Adjust offset if needed
324 if l.offsetIdx > idx {
325 l.offsetIdx--
326 } else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
327 l.offsetIdx = max(0, len(l.items)-1)
328 l.offsetLine = 0
329 }
330}
331
332// Focus sets the focus state of the list.
333func (l *List) Focus() {
334 l.focused = true
335}
336
337// Blur removes the focus state from the list.
338func (l *List) Blur() {
339 l.focused = false
340}
341
342// ScrollToTop scrolls the list to the top.
343func (l *List) ScrollToTop() {
344 l.offsetIdx = 0
345 l.offsetLine = 0
346}
347
348// ScrollToBottom scrolls the list to the bottom.
349func (l *List) ScrollToBottom() {
350 if len(l.items) == 0 {
351 return
352 }
353
354 // Scroll to the last item
355 var totalHeight int
356 for i := len(l.items) - 1; i >= 0; i-- {
357 item := l.getItem(i)
358 totalHeight += item.height
359 if l.gap > 0 && i < len(l.items)-1 {
360 totalHeight += l.gap
361 }
362 if totalHeight >= l.height {
363 l.offsetIdx = i
364 l.offsetLine = totalHeight - l.height
365 break
366 }
367 }
368 if totalHeight < l.height {
369 // All items fit in the viewport
370 l.ScrollToTop()
371 }
372}
373
374// ScrollToSelected scrolls the list to the selected item.
375func (l *List) ScrollToSelected() {
376 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
377 return
378 }
379
380 startIdx, endIdx := l.VisibleItemIndices()
381 if l.selectedIdx < startIdx {
382 // Selected item is above the visible range
383 l.offsetIdx = l.selectedIdx
384 l.offsetLine = 0
385 } else if l.selectedIdx > endIdx {
386 // Selected item is below the visible range
387 // Scroll so that the selected item is at the bottom
388 var totalHeight int
389 for i := l.selectedIdx; i >= 0; i-- {
390 item := l.getItem(i)
391 totalHeight += item.height
392 if l.gap > 0 && i < l.selectedIdx {
393 totalHeight += l.gap
394 }
395 if totalHeight >= l.height {
396 l.offsetIdx = i
397 l.offsetLine = totalHeight - l.height
398 break
399 }
400 }
401 if totalHeight < l.height {
402 // All items fit in the viewport
403 l.ScrollToTop()
404 }
405 }
406}
407
408// SelectedItemInView returns whether the selected item is currently in view.
409func (l *List) SelectedItemInView() bool {
410 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
411 return false
412 }
413 startIdx, endIdx := l.VisibleItemIndices()
414 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
415}
416
417// SetSelected sets the selected item index in the list.
418// It returns -1 if the index is out of bounds.
419func (l *List) SetSelected(index int) {
420 if index < 0 || index >= len(l.items) {
421 l.selectedIdx = -1
422 } else {
423 l.selectedIdx = index
424 }
425}
426
427// Selected returns the index of the currently selected item. It returns -1 if
428// no item is selected.
429func (l *List) Selected() int {
430 return l.selectedIdx
431}
432
433// IsSelectedFirst returns whether the first item is selected.
434func (l *List) IsSelectedFirst() bool {
435 return l.selectedIdx == 0
436}
437
438// IsSelectedLast returns whether the last item is selected.
439func (l *List) IsSelectedLast() bool {
440 return l.selectedIdx == len(l.items)-1
441}
442
443// SelectPrev selects the previous item in the list.
444// It returns whether the selection changed.
445func (l *List) SelectPrev() bool {
446 if l.selectedIdx > 0 {
447 l.selectedIdx--
448 return true
449 }
450 return false
451}
452
453// SelectNext selects the next item in the list.
454// It returns whether the selection changed.
455func (l *List) SelectNext() bool {
456 if l.selectedIdx < len(l.items)-1 {
457 l.selectedIdx++
458 return true
459 }
460 return false
461}
462
463// SelectFirst selects the first item in the list.
464// It returns whether the selection changed.
465func (l *List) SelectFirst() bool {
466 if len(l.items) > 0 {
467 l.selectedIdx = 0
468 return true
469 }
470 return false
471}
472
473// SelectLast selects the last item in the list.
474// It returns whether the selection changed.
475func (l *List) SelectLast() bool {
476 if len(l.items) > 0 {
477 l.selectedIdx = len(l.items) - 1
478 return true
479 }
480 return false
481}
482
483// SelectedItem returns the currently selected item. It may be nil if no item
484// is selected.
485func (l *List) SelectedItem() Item {
486 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
487 return nil
488 }
489 return l.items[l.selectedIdx]
490}
491
492// SelectFirstInView selects the first item currently in view.
493func (l *List) SelectFirstInView() {
494 startIdx, _ := l.VisibleItemIndices()
495 l.selectedIdx = startIdx
496}
497
498// SelectLastInView selects the last item currently in view.
499func (l *List) SelectLastInView() {
500 _, endIdx := l.VisibleItemIndices()
501 l.selectedIdx = endIdx
502}
503
504// ItemAt returns the item at the given index.
505func (l *List) ItemAt(index int) Item {
506 if index < 0 || index >= len(l.items) {
507 return nil
508 }
509 return l.items[index]
510}
511
512// ItemIndexAtPosition returns the item at the given viewport-relative y
513// coordinate. Returns the item index and the y offset within that item. It
514// returns -1, -1 if no item is found.
515func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
516 return l.findItemAtY(x, y)
517}
518
519// findItemAtY finds the item at the given viewport y coordinate.
520// Returns the item index and the y offset within that item. It returns -1, -1
521// if no item is found.
522func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
523 if y < 0 || y >= l.height {
524 return -1, -1
525 }
526
527 // Walk through visible items to find which one contains this y
528 currentIdx := l.offsetIdx
529 currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
530
531 for currentIdx < len(l.items) && currentLine < l.height {
532 item := l.getItem(currentIdx)
533 itemEndLine := currentLine + item.height
534
535 // Check if y is within this item's visible range
536 if y >= currentLine && y < itemEndLine {
537 // Found the item, calculate itemY (offset within the item)
538 itemY = y - currentLine
539 return currentIdx, itemY
540 }
541
542 // Move to next item
543 currentLine = itemEndLine
544 if l.gap > 0 {
545 currentLine += l.gap
546 }
547 currentIdx++
548 }
549
550 return -1, -1
551}
552
553// countLines counts the number of lines in a string.
554func countLines(s string) int {
555 if s == "" {
556 return 0
557 }
558 return strings.Count(s, "\n") + 1
559}