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