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