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