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