list.go

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