list.go

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