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