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// VisibleItemIndices 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) VisibleItemIndices() (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// RemoveItem removes the item at the given index from the list.
308func (l *List) RemoveItem(idx int) {
309	if idx < 0 || idx >= len(l.items) {
310		return
311	}
312
313	// Remove the item
314	l.items = append(l.items[:idx], l.items[idx+1:]...)
315
316	// Adjust selection if needed
317	if l.selectedIdx == idx {
318		l.selectedIdx = -1
319	} else if l.selectedIdx > idx {
320		l.selectedIdx--
321	}
322
323	// Adjust offset if needed
324	if l.offsetIdx > idx {
325		l.offsetIdx--
326	} else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
327		l.offsetIdx = max(0, len(l.items)-1)
328		l.offsetLine = 0
329	}
330}
331
332// Focus sets the focus state of the list.
333func (l *List) Focus() {
334	l.focused = true
335}
336
337// Blur removes the focus state from the list.
338func (l *List) Blur() {
339	l.focused = false
340}
341
342// ScrollToTop scrolls the list to the top.
343func (l *List) ScrollToTop() {
344	l.offsetIdx = 0
345	l.offsetLine = 0
346}
347
348// ScrollToBottom scrolls the list to the bottom.
349func (l *List) ScrollToBottom() {
350	if len(l.items) == 0 {
351		return
352	}
353
354	// Scroll to the last item
355	var totalHeight int
356	for i := len(l.items) - 1; i >= 0; i-- {
357		item := l.getItem(i)
358		totalHeight += item.height
359		if l.gap > 0 && i < len(l.items)-1 {
360			totalHeight += l.gap
361		}
362		if totalHeight >= l.height {
363			l.offsetIdx = i
364			l.offsetLine = totalHeight - l.height
365			break
366		}
367	}
368	if totalHeight < l.height {
369		// All items fit in the viewport
370		l.ScrollToTop()
371	}
372}
373
374// ScrollToSelected scrolls the list to the selected item.
375func (l *List) ScrollToSelected() {
376	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
377		return
378	}
379
380	startIdx, endIdx := l.VisibleItemIndices()
381	if l.selectedIdx < startIdx {
382		// Selected item is above the visible range
383		l.offsetIdx = l.selectedIdx
384		l.offsetLine = 0
385	} else if l.selectedIdx > endIdx {
386		// Selected item is below the visible range
387		// Scroll so that the selected item is at the bottom
388		var totalHeight int
389		for i := l.selectedIdx; i >= 0; i-- {
390			item := l.getItem(i)
391			totalHeight += item.height
392			if l.gap > 0 && i < l.selectedIdx {
393				totalHeight += l.gap
394			}
395			if totalHeight >= l.height {
396				l.offsetIdx = i
397				l.offsetLine = totalHeight - l.height
398				break
399			}
400		}
401		if totalHeight < l.height {
402			// All items fit in the viewport
403			l.ScrollToTop()
404		}
405	}
406}
407
408// SelectedItemInView returns whether the selected item is currently in view.
409func (l *List) SelectedItemInView() bool {
410	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
411		return false
412	}
413	startIdx, endIdx := l.VisibleItemIndices()
414	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
415}
416
417// SetSelected sets the selected item index in the list.
418// It returns -1 if the index is out of bounds.
419func (l *List) SetSelected(index int) {
420	if index < 0 || index >= len(l.items) {
421		l.selectedIdx = -1
422	} else {
423		l.selectedIdx = index
424	}
425}
426
427// Selected returns the index of the currently selected item. It returns -1 if
428// no item is selected.
429func (l *List) Selected() int {
430	return l.selectedIdx
431}
432
433// IsSelectedFirst returns whether the first item is selected.
434func (l *List) IsSelectedFirst() bool {
435	return l.selectedIdx == 0
436}
437
438// IsSelectedLast returns whether the last item is selected.
439func (l *List) IsSelectedLast() bool {
440	return l.selectedIdx == len(l.items)-1
441}
442
443// SelectPrev selects the previous item in the list.
444// It returns whether the selection changed.
445func (l *List) SelectPrev() bool {
446	if l.selectedIdx > 0 {
447		l.selectedIdx--
448		return true
449	}
450	return false
451}
452
453// SelectNext selects the next item in the list.
454// It returns whether the selection changed.
455func (l *List) SelectNext() bool {
456	if l.selectedIdx < len(l.items)-1 {
457		l.selectedIdx++
458		return true
459	}
460	return false
461}
462
463// SelectFirst selects the first item in the list.
464// It returns whether the selection changed.
465func (l *List) SelectFirst() bool {
466	if len(l.items) > 0 {
467		l.selectedIdx = 0
468		return true
469	}
470	return false
471}
472
473// SelectLast selects the last item in the list.
474// It returns whether the selection changed.
475func (l *List) SelectLast() bool {
476	if len(l.items) > 0 {
477		l.selectedIdx = len(l.items) - 1
478		return true
479	}
480	return false
481}
482
483// SelectedItem returns the currently selected item. It may be nil if no item
484// is selected.
485func (l *List) SelectedItem() Item {
486	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
487		return nil
488	}
489	return l.items[l.selectedIdx]
490}
491
492// SelectFirstInView selects the first item currently in view.
493func (l *List) SelectFirstInView() {
494	startIdx, _ := l.VisibleItemIndices()
495	l.selectedIdx = startIdx
496}
497
498// SelectLastInView selects the last item currently in view.
499func (l *List) SelectLastInView() {
500	_, endIdx := l.VisibleItemIndices()
501	l.selectedIdx = endIdx
502}
503
504// ItemAt returns the item at the given index.
505func (l *List) ItemAt(index int) Item {
506	if index < 0 || index >= len(l.items) {
507		return nil
508	}
509	return l.items[index]
510}
511
512// ItemIndexAtPosition returns the item at the given viewport-relative y
513// coordinate. Returns the item index and the y offset within that item. It
514// returns -1, -1 if no item is found.
515func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
516	return l.findItemAtY(x, y)
517}
518
519// findItemAtY finds the item at the given viewport y coordinate.
520// Returns the item index and the y offset within that item. It returns -1, -1
521// if no item is found.
522func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
523	if y < 0 || y >= l.height {
524		return -1, -1
525	}
526
527	// Walk through visible items to find which one contains this y
528	currentIdx := l.offsetIdx
529	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
530
531	for currentIdx < len(l.items) && currentLine < l.height {
532		item := l.getItem(currentIdx)
533		itemEndLine := currentLine + item.height
534
535		// Check if y is within this item's visible range
536		if y >= currentLine && y < itemEndLine {
537			// Found the item, calculate itemY (offset within the item)
538			itemY = y - currentLine
539			return currentIdx, itemY
540		}
541
542		// Move to next item
543		currentLine = itemEndLine
544		if l.gap > 0 {
545			currentLine += l.gap
546		}
547		currentIdx++
548	}
549
550	return -1, -1
551}
552
553// countLines counts the number of lines in a string.
554func countLines(s string) int {
555	if s == "" {
556		return 0
557	}
558	return strings.Count(s, "\n") + 1
559}