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	// Focus and selection state
 21	focused     bool
 22	selectedIdx int // The current selected index -1 means no selection
 23
 24	// offsetIdx is the index of the first visible item in the viewport.
 25	offsetIdx int
 26	// offsetLine is the number of lines of the item at offsetIdx that are
 27	// scrolled out of view (above the viewport).
 28	// It must always be >= 0.
 29	offsetLine int
 30
 31	// renderCallbacks is a list of callbacks to apply when rendering items.
 32	renderCallbacks []func(idx, selectedIdx int, item Item) Item
 33}
 34
 35// renderedItem holds the rendered content and height of an item.
 36type renderedItem struct {
 37	content string
 38	height  int
 39}
 40
 41// NewList creates a new lazy-loaded list.
 42func NewList(items ...Item) *List {
 43	l := new(List)
 44	l.items = items
 45	l.selectedIdx = -1
 46	return l
 47}
 48
 49// RegisterRenderCallback registers a callback to be called when rendering
 50// items. This can be used to modify items before they are rendered.
 51func (l *List) RegisterRenderCallback(cb func(idx, selectedIdx int, item Item) Item) {
 52	l.renderCallbacks = append(l.renderCallbacks, cb)
 53}
 54
 55// SetSize sets the size of the list viewport.
 56func (l *List) SetSize(width, height int) {
 57	l.width = width
 58	l.height = height
 59}
 60
 61// SetGap sets the gap between items.
 62func (l *List) SetGap(gap int) {
 63	l.gap = gap
 64}
 65
 66// Width returns the width of the list viewport.
 67func (l *List) Width() int {
 68	return l.width
 69}
 70
 71// Height returns the height of the list viewport.
 72func (l *List) Height() int {
 73	return l.height
 74}
 75
 76// Len returns the number of items in the list.
 77func (l *List) Len() int {
 78	return len(l.items)
 79}
 80
 81// getItem renders (if needed) and returns the item at the given index.
 82func (l *List) getItem(idx int) renderedItem {
 83	if idx < 0 || idx >= len(l.items) {
 84		return renderedItem{}
 85	}
 86
 87	item := l.items[idx]
 88	if len(l.renderCallbacks) > 0 {
 89		for _, cb := range l.renderCallbacks {
 90			if it := cb(idx, l.selectedIdx, item); it != nil {
 91				item = it
 92			}
 93		}
 94	}
 95
 96	if focusable, isFocusable := item.(Focusable); isFocusable {
 97		focusable.SetFocused(l.focused && idx == l.selectedIdx)
 98	}
 99
100	rendered := item.Render(l.width)
101	rendered = strings.TrimRight(rendered, "\n")
102	height := countLines(rendered)
103	ri := renderedItem{
104		content: rendered,
105		height:  height,
106	}
107
108	return ri
109}
110
111// ScrollToIndex scrolls the list to the given item index.
112func (l *List) ScrollToIndex(index int) {
113	if index < 0 {
114		index = 0
115	}
116	if index >= len(l.items) {
117		index = len(l.items) - 1
118	}
119	l.offsetIdx = index
120	l.offsetLine = 0
121}
122
123// ScrollBy scrolls the list by the given number of lines.
124func (l *List) ScrollBy(lines int) {
125	if len(l.items) == 0 || lines == 0 {
126		return
127	}
128
129	if lines > 0 {
130		// Scroll down
131		// Calculate from the bottom how many lines needed to anchor the last
132		// item to the bottom
133		var totalLines int
134		var lastItemIdx int // the last item that can be partially visible
135		for i := len(l.items) - 1; i >= 0; i-- {
136			item := l.getItem(i)
137			totalLines += item.height
138			if l.gap > 0 && i < len(l.items)-1 {
139				totalLines += l.gap
140			}
141			if totalLines > l.height-1 {
142				lastItemIdx = i
143				break
144			}
145		}
146
147		// Now scroll down by lines
148		var item renderedItem
149		l.offsetLine += lines
150		for {
151			item = l.getItem(l.offsetIdx)
152			totalHeight := item.height
153			if l.gap > 0 {
154				totalHeight += l.gap
155			}
156
157			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
158				// Valid offset
159				break
160			}
161
162			// Move to next item
163			l.offsetLine -= totalHeight
164			l.offsetIdx++
165		}
166
167		if l.offsetLine >= item.height {
168			l.offsetLine = item.height
169		}
170	} else if lines < 0 {
171		// Scroll up
172		l.offsetLine += lines // lines is negative
173		for l.offsetLine < 0 {
174			if l.offsetIdx <= 0 {
175				// Reached top
176				l.ScrollToTop()
177				break
178			}
179
180			// Move to previous item
181			l.offsetIdx--
182			prevItem := l.getItem(l.offsetIdx)
183			totalHeight := prevItem.height
184			if l.gap > 0 {
185				totalHeight += l.gap
186			}
187			l.offsetLine += totalHeight
188		}
189	}
190}
191
192// findVisibleItems finds the range of items that are visible in the viewport.
193// This is used for checking if selected item is in view.
194func (l *List) findVisibleItems() (startIdx, endIdx int) {
195	if len(l.items) == 0 {
196		return 0, 0
197	}
198
199	startIdx = l.offsetIdx
200	currentIdx := startIdx
201	visibleHeight := -l.offsetLine
202
203	for currentIdx < len(l.items) {
204		item := l.getItem(currentIdx)
205		visibleHeight += item.height
206		if l.gap > 0 {
207			visibleHeight += l.gap
208		}
209
210		if visibleHeight >= l.height {
211			break
212		}
213		currentIdx++
214	}
215
216	endIdx = currentIdx
217	if endIdx >= len(l.items) {
218		endIdx = len(l.items) - 1
219	}
220
221	return startIdx, endIdx
222}
223
224// Render renders the list and returns the visible lines.
225func (l *List) Render() string {
226	if len(l.items) == 0 {
227		return ""
228	}
229
230	var lines []string
231	currentIdx := l.offsetIdx
232	currentOffset := l.offsetLine
233
234	linesNeeded := l.height
235
236	for linesNeeded > 0 && currentIdx < len(l.items) {
237		item := l.getItem(currentIdx)
238		itemLines := strings.Split(item.content, "\n")
239		itemHeight := len(itemLines)
240
241		if currentOffset >= 0 && currentOffset < itemHeight {
242			// Add visible content lines
243			lines = append(lines, itemLines[currentOffset:]...)
244
245			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
246			// But in the loop we can just add it and trim later
247			if l.gap > 0 {
248				for i := 0; i < l.gap; i++ {
249					lines = append(lines, "")
250				}
251			}
252		} else {
253			// offsetLine starts in the gap
254			gapOffset := currentOffset - itemHeight
255			gapRemaining := l.gap - gapOffset
256			if gapRemaining > 0 {
257				for range gapRemaining {
258					lines = append(lines, "")
259				}
260			}
261		}
262
263		linesNeeded = l.height - len(lines)
264		currentIdx++
265		currentOffset = 0 // Reset offset for subsequent items
266	}
267
268	if len(lines) > l.height {
269		lines = lines[:l.height]
270	}
271
272	return strings.Join(lines, "\n")
273}
274
275// PrependItems prepends items to the list.
276func (l *List) PrependItems(items ...Item) {
277	l.items = append(items, l.items...)
278
279	// Keep view position relative to the content that was visible
280	l.offsetIdx += len(items)
281
282	// Update selection index if valid
283	if l.selectedIdx != -1 {
284		l.selectedIdx += len(items)
285	}
286}
287
288// SetItems sets the items in the list.
289func (l *List) SetItems(items ...Item) {
290	l.setItems(true, items...)
291}
292
293// setItems sets the items in the list. If evict is true, it clears the
294// rendered item cache.
295func (l *List) setItems(evict bool, items ...Item) {
296	l.items = items
297	l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
298	l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
299	l.offsetLine = 0
300}
301
302// AppendItems appends items to the list.
303func (l *List) AppendItems(items ...Item) {
304	l.items = append(l.items, items...)
305}
306
307// Focus sets the focus state of the list.
308func (l *List) Focus() {
309	l.focused = true
310}
311
312// Blur removes the focus state from the list.
313func (l *List) Blur() {
314	l.focused = false
315}
316
317// ScrollToTop scrolls the list to the top.
318func (l *List) ScrollToTop() {
319	l.offsetIdx = 0
320	l.offsetLine = 0
321}
322
323// ScrollToBottom scrolls the list to the bottom.
324func (l *List) ScrollToBottom() {
325	if len(l.items) == 0 {
326		return
327	}
328
329	// Scroll to the last item
330	var totalHeight int
331	for i := len(l.items) - 1; i >= 0; i-- {
332		item := l.getItem(i)
333		totalHeight += item.height
334		if l.gap > 0 && i < len(l.items)-1 {
335			totalHeight += l.gap
336		}
337		if totalHeight >= l.height {
338			l.offsetIdx = i
339			l.offsetLine = totalHeight - l.height
340			break
341		}
342	}
343	if totalHeight < l.height {
344		// All items fit in the viewport
345		l.ScrollToTop()
346	}
347}
348
349// ScrollToSelected scrolls the list to the selected item.
350func (l *List) ScrollToSelected() {
351	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
352		return
353	}
354
355	startIdx, endIdx := l.findVisibleItems()
356	if l.selectedIdx < startIdx {
357		// Selected item is above the visible range
358		l.offsetIdx = l.selectedIdx
359		l.offsetLine = 0
360	} else if l.selectedIdx > endIdx {
361		// Selected item is below the visible range
362		// Scroll so that the selected item is at the bottom
363		var totalHeight int
364		for i := l.selectedIdx; i >= 0; i-- {
365			item := l.getItem(i)
366			totalHeight += item.height
367			if l.gap > 0 && i < l.selectedIdx {
368				totalHeight += l.gap
369			}
370			if totalHeight >= l.height {
371				l.offsetIdx = i
372				l.offsetLine = totalHeight - l.height
373				break
374			}
375		}
376		if totalHeight < l.height {
377			// All items fit in the viewport
378			l.ScrollToTop()
379		}
380	}
381}
382
383// SelectedItemInView returns whether the selected item is currently in view.
384func (l *List) SelectedItemInView() bool {
385	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
386		return false
387	}
388	startIdx, endIdx := l.findVisibleItems()
389	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
390}
391
392// SetSelected sets the selected item index in the list.
393func (l *List) SetSelected(index int) {
394	if index < 0 || index >= len(l.items) {
395		l.selectedIdx = -1
396	} else {
397		l.selectedIdx = index
398	}
399}
400
401// SelectPrev selects the previous item in the list.
402func (l *List) SelectPrev() {
403	if l.selectedIdx > 0 {
404		l.selectedIdx--
405	}
406}
407
408// SelectNext selects the next item in the list.
409func (l *List) SelectNext() {
410	if l.selectedIdx < len(l.items)-1 {
411		l.selectedIdx++
412	}
413}
414
415// SelectFirst selects the first item in the list.
416func (l *List) SelectFirst() {
417	if len(l.items) > 0 {
418		l.selectedIdx = 0
419	}
420}
421
422// SelectLast selects the last item in the list.
423func (l *List) SelectLast() {
424	if len(l.items) > 0 {
425		l.selectedIdx = len(l.items) - 1
426	}
427}
428
429// SelectedItem returns the currently selected item. It may be nil if no item
430// is selected.
431func (l *List) SelectedItem() Item {
432	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
433		return nil
434	}
435	return l.items[l.selectedIdx]
436}
437
438// SelectFirstInView selects the first item currently in view.
439func (l *List) SelectFirstInView() {
440	startIdx, _ := l.findVisibleItems()
441	l.selectedIdx = startIdx
442}
443
444// SelectLastInView selects the last item currently in view.
445func (l *List) SelectLastInView() {
446	_, endIdx := l.findVisibleItems()
447	l.selectedIdx = endIdx
448}
449
450// ItemAt returns the item at the given index.
451func (l *List) ItemAt(index int) Item {
452	if index < 0 || index >= len(l.items) {
453		return nil
454	}
455	return l.items[index]
456}
457
458// ItemIndexAtPosition returns the item at the given viewport-relative y
459// coordinate. Returns the item index and the y offset within that item. It
460// returns -1, -1 if no item is found.
461func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
462	return l.findItemAtY(x, y)
463}
464
465// findItemAtY finds the item at the given viewport y coordinate.
466// Returns the item index and the y offset within that item. It returns -1, -1
467// if no item is found.
468func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
469	if y < 0 || y >= l.height {
470		return -1, -1
471	}
472
473	// Walk through visible items to find which one contains this y
474	currentIdx := l.offsetIdx
475	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
476
477	for currentIdx < len(l.items) && currentLine < l.height {
478		item := l.getItem(currentIdx)
479		itemEndLine := currentLine + item.height
480
481		// Check if y is within this item's visible range
482		if y >= currentLine && y < itemEndLine {
483			// Found the item, calculate itemY (offset within the item)
484			itemY = y - currentLine
485			return currentIdx, itemY
486		}
487
488		// Move to next item
489		currentLine = itemEndLine
490		if l.gap > 0 {
491			currentLine += l.gap
492		}
493		currentIdx++
494	}
495
496	return -1, -1
497}
498
499// countLines counts the number of lines in a string.
500func countLines(s string) int {
501	if s == "" {
502		return 0
503	}
504	return strings.Count(s, "\n") + 1
505}