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