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