list.go

  1package list
  2
  3import (
  4	"strings"
  5
  6	"github.com/charmbracelet/x/ansi"
  7)
  8
  9// List represents a list of items that can be lazily rendered. A list is
 10// always rendered like a chat conversation where items are stacked vertically
 11// from top to bottom.
 12type List struct {
 13	// Viewport size
 14	width, height int
 15
 16	// Items in the list
 17	items []Item
 18
 19	// Gap between items (0 or less means no gap)
 20	gap int
 21
 22	// Focus and selection state
 23	focused     bool
 24	selectedIdx int // The current selected index -1 means no selection
 25
 26	// Mouse state
 27	mouseDown       bool
 28	mouseDownItem   int          // Item index where mouse was pressed
 29	mouseDownX      int          // X position in item content (character offset)
 30	mouseDownY      int          // Y position in item (line offset)
 31	mouseDragItem   int          // Current item index being dragged over
 32	mouseDragX      int          // Current X in item content
 33	mouseDragY      int          // Current Y in item
 34	lastHighlighted map[int]bool // Track which items were highlighted in last update
 35
 36	// offsetIdx is the index of the first visible item in the viewport.
 37	offsetIdx int
 38	// offsetLine is the number of lines of the item at offsetIdx that are
 39	// scrolled out of view (above the viewport).
 40	// It must always be >= 0.
 41	offsetLine int
 42}
 43
 44// renderedItem holds the rendered content and height of an item.
 45type renderedItem struct {
 46	content string
 47	height  int
 48}
 49
 50// NewList creates a new lazy-loaded list.
 51func NewList(items ...Item) *List {
 52	l := new(List)
 53	l.items = items
 54	l.selectedIdx = -1
 55	l.mouseDownItem = -1
 56	l.mouseDragItem = -1
 57	l.lastHighlighted = make(map[int]bool)
 58	return l
 59}
 60
 61// SetSize sets the size of the list viewport.
 62func (l *List) SetSize(width, height int) {
 63	l.width = width
 64	l.height = height
 65	// l.normalizeOffsets()
 66}
 67
 68// SetGap sets the gap between items.
 69func (l *List) SetGap(gap int) {
 70	l.gap = gap
 71}
 72
 73// Width returns the width of the list viewport.
 74func (l *List) Width() int {
 75	return l.width
 76}
 77
 78// Height returns the height of the list viewport.
 79func (l *List) Height() int {
 80	return l.height
 81}
 82
 83// Len returns the number of items in the list.
 84func (l *List) Len() int {
 85	return len(l.items)
 86}
 87
 88// getItem renders (if needed) and returns the item at the given index.
 89func (l *List) getItem(idx int) renderedItem {
 90	if idx < 0 || idx >= len(l.items) {
 91		return renderedItem{}
 92	}
 93
 94	item := l.items[idx]
 95	if hi, ok := item.(Highlightable); ok {
 96		// Apply highlight
 97		startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange()
 98		sLine, sCol, eLine, eCol := -1, -1, -1, -1
 99		if idx >= startItemIdx && idx <= endItemIdx {
100			if idx == startItemIdx && idx == endItemIdx {
101				// Single item selection
102				sLine = startLine
103				sCol = startCol
104				eLine = endLine
105				eCol = endCol
106			} else if idx == startItemIdx {
107				// First item - from start position to end of item
108				sLine = startLine
109				sCol = startCol
110				eLine = -1
111				eCol = -1
112			} else if idx == endItemIdx {
113				// Last item - from start of item to end position
114				sLine = 0
115				sCol = 0
116				eLine = endLine
117				eCol = endCol
118			} else {
119				// Middle item - fully highlighted
120				sLine = 0
121				sCol = 0
122				eLine = -1
123				eCol = -1
124			}
125		}
126
127		hi.Highlight(sLine, sCol, eLine, eCol)
128	}
129
130	if focusable, isFocusable := item.(Focusable); isFocusable {
131		focusable.SetFocused(l.focused && idx == l.selectedIdx)
132	}
133
134	rendered := item.Render(l.width)
135	rendered = strings.TrimRight(rendered, "\n")
136	height := countLines(rendered)
137	ri := renderedItem{
138		content: rendered,
139		height:  height,
140	}
141
142	return ri
143}
144
145// ScrollToIndex scrolls the list to the given item index.
146func (l *List) ScrollToIndex(index int) {
147	if index < 0 {
148		index = 0
149	}
150	if index >= len(l.items) {
151		index = len(l.items) - 1
152	}
153	l.offsetIdx = index
154	l.offsetLine = 0
155}
156
157// ScrollBy scrolls the list by the given number of lines.
158func (l *List) ScrollBy(lines int) {
159	if len(l.items) == 0 || lines == 0 {
160		return
161	}
162
163	if lines > 0 {
164		// Scroll down
165		// Calculate from the bottom how many lines needed to anchor the last
166		// item to the bottom
167		var totalLines int
168		var lastItemIdx int // the last item that can be partially visible
169		for i := len(l.items) - 1; i >= 0; i-- {
170			item := l.getItem(i)
171			totalLines += item.height
172			if l.gap > 0 && i < len(l.items)-1 {
173				totalLines += l.gap
174			}
175			if totalLines > l.height-1 {
176				lastItemIdx = i
177				break
178			}
179		}
180
181		// Now scroll down by lines
182		var item renderedItem
183		l.offsetLine += lines
184		for {
185			item = l.getItem(l.offsetIdx)
186			totalHeight := item.height
187			if l.gap > 0 {
188				totalHeight += l.gap
189			}
190
191			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
192				// Valid offset
193				break
194			}
195
196			// Move to next item
197			l.offsetLine -= totalHeight
198			l.offsetIdx++
199		}
200
201		if l.offsetLine >= item.height {
202			l.offsetLine = item.height
203		}
204	} else if lines < 0 {
205		// Scroll up
206		l.offsetLine += lines // lines is negative
207		for l.offsetLine < 0 {
208			if l.offsetIdx <= 0 {
209				// Reached top
210				l.ScrollToTop()
211				break
212			}
213
214			// Move to previous item
215			l.offsetIdx--
216			prevItem := l.getItem(l.offsetIdx)
217			totalHeight := prevItem.height
218			if l.gap > 0 {
219				totalHeight += l.gap
220			}
221			l.offsetLine += totalHeight
222		}
223	}
224}
225
226// findVisibleItems finds the range of items that are visible in the viewport.
227// This is used for checking if selected item is in view.
228func (l *List) findVisibleItems() (startIdx, endIdx int) {
229	if len(l.items) == 0 {
230		return 0, 0
231	}
232
233	startIdx = l.offsetIdx
234	currentIdx := startIdx
235	visibleHeight := -l.offsetLine
236
237	for currentIdx < len(l.items) {
238		item := l.getItem(currentIdx)
239		visibleHeight += item.height
240		if l.gap > 0 {
241			visibleHeight += l.gap
242		}
243
244		if visibleHeight >= l.height {
245			break
246		}
247		currentIdx++
248	}
249
250	endIdx = currentIdx
251	if endIdx >= len(l.items) {
252		endIdx = len(l.items) - 1
253	}
254
255	return startIdx, endIdx
256}
257
258// Render renders the list and returns the visible lines.
259func (l *List) Render() string {
260	if len(l.items) == 0 {
261		return ""
262	}
263
264	var lines []string
265	currentIdx := l.offsetIdx
266	currentOffset := l.offsetLine
267
268	linesNeeded := l.height
269
270	for linesNeeded > 0 && currentIdx < len(l.items) {
271		item := l.getItem(currentIdx)
272		itemLines := strings.Split(item.content, "\n")
273		itemHeight := len(itemLines)
274
275		if currentOffset >= 0 && currentOffset < itemHeight {
276			// Add visible content lines
277			lines = append(lines, itemLines[currentOffset:]...)
278
279			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
280			// But in the loop we can just add it and trim later
281			if l.gap > 0 {
282				for i := 0; i < l.gap; i++ {
283					lines = append(lines, "")
284				}
285			}
286		} else {
287			// offsetLine starts in the gap
288			gapOffset := currentOffset - itemHeight
289			gapRemaining := l.gap - gapOffset
290			if gapRemaining > 0 {
291				for range gapRemaining {
292					lines = append(lines, "")
293				}
294			}
295		}
296
297		linesNeeded = l.height - len(lines)
298		currentIdx++
299		currentOffset = 0 // Reset offset for subsequent items
300	}
301
302	if len(lines) > l.height {
303		lines = lines[:l.height]
304	}
305
306	return strings.Join(lines, "\n")
307}
308
309// PrependItems prepends items to the list.
310func (l *List) PrependItems(items ...Item) {
311	l.items = append(items, l.items...)
312
313	// Keep view position relative to the content that was visible
314	l.offsetIdx += len(items)
315
316	// Update selection index if valid
317	if l.selectedIdx != -1 {
318		l.selectedIdx += len(items)
319	}
320}
321
322// SetItems sets the items in the list.
323func (l *List) SetItems(items ...Item) {
324	l.setItems(true, items...)
325}
326
327// setItems sets the items in the list. If evict is true, it clears the
328// rendered item cache.
329func (l *List) setItems(evict bool, items ...Item) {
330	l.items = items
331	l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
332	l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
333	l.offsetLine = 0
334}
335
336// AppendItems appends items to the list.
337func (l *List) AppendItems(items ...Item) {
338	l.items = append(l.items, items...)
339}
340
341// Focus sets the focus state of the list.
342func (l *List) Focus() {
343	l.focused = true
344}
345
346// Blur removes the focus state from the list.
347func (l *List) Blur() {
348	l.focused = false
349}
350
351// ScrollToTop scrolls the list to the top.
352func (l *List) ScrollToTop() {
353	l.offsetIdx = 0
354	l.offsetLine = 0
355}
356
357// ScrollToBottom scrolls the list to the bottom.
358func (l *List) ScrollToBottom() {
359	if len(l.items) == 0 {
360		return
361	}
362
363	// Scroll to the last item
364	var totalHeight int
365	for i := len(l.items) - 1; i >= 0; i-- {
366		item := l.getItem(i)
367		totalHeight += item.height
368		if l.gap > 0 && i < len(l.items)-1 {
369			totalHeight += l.gap
370		}
371		if totalHeight >= l.height {
372			l.offsetIdx = i
373			l.offsetLine = totalHeight - l.height
374			break
375		}
376	}
377	if totalHeight < l.height {
378		// All items fit in the viewport
379		l.ScrollToTop()
380	}
381}
382
383// ScrollToSelected scrolls the list to the selected item.
384func (l *List) ScrollToSelected() {
385	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
386		return
387	}
388
389	startIdx, endIdx := l.findVisibleItems()
390	if l.selectedIdx < startIdx {
391		// Selected item is above the visible range
392		l.offsetIdx = l.selectedIdx
393		l.offsetLine = 0
394	} else if l.selectedIdx > endIdx {
395		// Selected item is below the visible range
396		// Scroll so that the selected item is at the bottom
397		var totalHeight int
398		for i := l.selectedIdx; i >= 0; i-- {
399			item := l.getItem(i)
400			totalHeight += item.height
401			if l.gap > 0 && i < l.selectedIdx {
402				totalHeight += l.gap
403			}
404			if totalHeight >= l.height {
405				l.offsetIdx = i
406				l.offsetLine = totalHeight - l.height
407				break
408			}
409		}
410		if totalHeight < l.height {
411			// All items fit in the viewport
412			l.ScrollToTop()
413		}
414	}
415}
416
417// SelectedItemInView returns whether the selected item is currently in view.
418func (l *List) SelectedItemInView() bool {
419	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
420		return false
421	}
422	startIdx, endIdx := l.findVisibleItems()
423	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
424}
425
426// SetSelected sets the selected item index in the list.
427func (l *List) SetSelected(index int) {
428	if index < 0 || index >= len(l.items) {
429		l.selectedIdx = -1
430	} else {
431		l.selectedIdx = index
432	}
433}
434
435// SelectPrev selects the previous item in the list.
436func (l *List) SelectPrev() {
437	if l.selectedIdx > 0 {
438		l.selectedIdx--
439	}
440}
441
442// SelectNext selects the next item in the list.
443func (l *List) SelectNext() {
444	if l.selectedIdx < len(l.items)-1 {
445		l.selectedIdx++
446	}
447}
448
449// SelectFirst selects the first item in the list.
450func (l *List) SelectFirst() {
451	if len(l.items) > 0 {
452		l.selectedIdx = 0
453	}
454}
455
456// SelectLast selects the last item in the list.
457func (l *List) SelectLast() {
458	if len(l.items) > 0 {
459		l.selectedIdx = len(l.items) - 1
460	}
461}
462
463// SelectedItem returns the currently selected item. It may be nil if no item
464// is selected.
465func (l *List) SelectedItem() Item {
466	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
467		return nil
468	}
469	return l.items[l.selectedIdx]
470}
471
472// SelectFirstInView selects the first item currently in view.
473func (l *List) SelectFirstInView() {
474	startIdx, _ := l.findVisibleItems()
475	l.selectedIdx = startIdx
476}
477
478// SelectLastInView selects the last item currently in view.
479func (l *List) SelectLastInView() {
480	_, endIdx := l.findVisibleItems()
481	l.selectedIdx = endIdx
482}
483
484// HandleMouseDown handles mouse down events at the given line in the viewport.
485// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
486// Returns true if the event was handled.
487func (l *List) HandleMouseDown(x, y int) bool {
488	if len(l.items) == 0 {
489		return false
490	}
491
492	// Find which item was clicked
493	itemIdx, itemY := l.findItemAtY(x, y)
494	if itemIdx < 0 {
495		return false
496	}
497
498	l.mouseDown = true
499	l.mouseDownItem = itemIdx
500	l.mouseDownX = x
501	l.mouseDownY = itemY
502	l.mouseDragItem = itemIdx
503	l.mouseDragX = x
504	l.mouseDragY = itemY
505
506	// Select the clicked item
507	l.SetSelected(itemIdx)
508
509	if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
510		clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
511	}
512
513	return true
514}
515
516// HandleMouseUp handles mouse up events at the given line in the viewport.
517// Returns true if the event was handled.
518func (l *List) HandleMouseUp(x, y int) bool {
519	if !l.mouseDown {
520		return false
521	}
522
523	l.mouseDown = false
524
525	return true
526}
527
528// HandleMouseDrag handles mouse drag events at the given line in the viewport.
529// x and y are viewport-relative coordinates.
530// Returns true if the event was handled.
531func (l *List) HandleMouseDrag(x, y int) bool {
532	if !l.mouseDown {
533		return false
534	}
535
536	if len(l.items) == 0 {
537		return false
538	}
539
540	// Find which item we're dragging over
541	itemIdx, itemY := l.findItemAtY(x, y)
542	if itemIdx < 0 {
543		return false
544	}
545
546	l.mouseDragItem = itemIdx
547	l.mouseDragX = x
548	l.mouseDragY = itemY
549
550	return true
551}
552
553// ClearHighlight clears any active text highlighting.
554func (l *List) ClearHighlight() {
555	l.mouseDownItem = -1
556	l.mouseDragItem = -1
557	l.lastHighlighted = make(map[int]bool)
558}
559
560// findItemAtY finds the item at the given viewport y coordinate.
561// Returns the item index and the y offset within that item. It returns -1, -1
562// if no item is found.
563func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
564	if y < 0 || y >= l.height {
565		return -1, -1
566	}
567
568	// Walk through visible items to find which one contains this y
569	currentIdx := l.offsetIdx
570	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
571
572	for currentIdx < len(l.items) && currentLine < l.height {
573		item := l.getItem(currentIdx)
574		itemEndLine := currentLine + item.height
575
576		// Check if y is within this item's visible range
577		if y >= currentLine && y < itemEndLine {
578			// Found the item, calculate itemY (offset within the item)
579			itemY = y - currentLine
580			return currentIdx, itemY
581		}
582
583		// Move to next item
584		currentLine = itemEndLine
585		if l.gap > 0 {
586			currentLine += l.gap
587		}
588		currentIdx++
589	}
590
591	return -1, -1
592}
593
594// getHighlightRange returns the current highlight range.
595func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
596	if l.mouseDownItem < 0 {
597		return -1, -1, -1, -1, -1, -1
598	}
599
600	downItemIdx := l.mouseDownItem
601	dragItemIdx := l.mouseDragItem
602
603	// Determine selection direction
604	draggingDown := dragItemIdx > downItemIdx ||
605		(dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
606		(dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
607
608	if draggingDown {
609		// Normal forward selection
610		startItemIdx = downItemIdx
611		startLine = l.mouseDownY
612		startCol = l.mouseDownX
613		endItemIdx = dragItemIdx
614		endLine = l.mouseDragY
615		endCol = l.mouseDragX
616	} else {
617		// Backward selection (dragging up)
618		startItemIdx = dragItemIdx
619		startLine = l.mouseDragY
620		startCol = l.mouseDragX
621		endItemIdx = downItemIdx
622		endLine = l.mouseDownY
623		endCol = l.mouseDownX
624	}
625
626	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
627}
628
629// countLines counts the number of lines in a string.
630func countLines(s string) int {
631	if s == "" {
632		return 0
633	}
634	return strings.Count(s, "\n") + 1
635}