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