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		height := countLines(rendered)
166
167		ri = renderedItem{
168			content: rendered,
169			height:  height,
170		}
171
172		l.renderedItems[idx] = ri
173	}
174
175	if !process {
176		// Simply return cached rendered item with frame size applied
177		if vfs := style.GetVerticalFrameSize(); vfs > 0 {
178			ri.height += vfs
179		}
180		return ri
181	}
182
183	// We apply highlighting before focus styling so that focus styling
184	// overrides highlight styles.
185	if l.mouseDownItem >= 0 {
186		l.applyHighlight(idx, &ri)
187	}
188
189	if isFocusable {
190		// Apply focus/blur styling if needed
191		rendered := style.Render(ri.content)
192		height := countLines(rendered)
193		ri.content = rendered
194		ri.height = height
195	}
196
197	return ri
198}
199
200// invalidateItem invalidates the cached rendered content of the item at the
201// given index.
202func (l *List) invalidateItem(idx int) {
203	delete(l.renderedItems, idx)
204}
205
206// ScrollToIndex scrolls the list to the given item index.
207func (l *List) ScrollToIndex(index int) {
208	if index < 0 {
209		index = 0
210	}
211	if index >= len(l.items) {
212		index = len(l.items) - 1
213	}
214	l.offsetIdx = index
215	l.offsetLine = 0
216}
217
218// ScrollBy scrolls the list by the given number of lines.
219func (l *List) ScrollBy(lines int) {
220	if len(l.items) == 0 || lines == 0 {
221		return
222	}
223
224	if lines > 0 {
225		// Scroll down
226		// Calculate from the bottom how many lines needed to anchor the last
227		// item to the bottom
228		var totalLines int
229		var lastItemIdx int // the last item that can be partially visible
230		for i := len(l.items) - 1; i >= 0; i-- {
231			item := l.getItem(i)
232			totalLines += item.height
233			if l.gap > 0 && i < len(l.items)-1 {
234				totalLines += l.gap
235			}
236			if totalLines > l.height-1 {
237				lastItemIdx = i
238				break
239			}
240		}
241
242		// Now scroll down by lines
243		var item renderedItem
244		l.offsetLine += lines
245		for {
246			item = l.getItem(l.offsetIdx)
247			totalHeight := item.height
248			if l.gap > 0 {
249				totalHeight += l.gap
250			}
251
252			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
253				// Valid offset
254				break
255			}
256
257			// Move to next item
258			l.offsetLine -= totalHeight
259			l.offsetIdx++
260		}
261
262		if l.offsetLine >= item.height {
263			l.offsetLine = item.height
264		}
265	} else if lines < 0 {
266		// Scroll up
267		l.offsetLine += lines // lines is negative
268		for l.offsetLine < 0 {
269			if l.offsetIdx <= 0 {
270				// Reached top
271				l.ScrollToTop()
272				break
273			}
274
275			// Move to previous item
276			l.offsetIdx--
277			prevItem := l.getItem(l.offsetIdx)
278			totalHeight := prevItem.height
279			if l.gap > 0 {
280				totalHeight += l.gap
281			}
282			l.offsetLine += totalHeight
283		}
284	}
285}
286
287// findVisibleItems finds the range of items that are visible in the viewport.
288// This is used for checking if selected item is in view.
289func (l *List) findVisibleItems() (startIdx, endIdx int) {
290	if len(l.items) == 0 {
291		return 0, 0
292	}
293
294	startIdx = l.offsetIdx
295	currentIdx := startIdx
296	visibleHeight := -l.offsetLine
297
298	for currentIdx < len(l.items) {
299		item := l.getItem(currentIdx)
300		visibleHeight += item.height
301		if l.gap > 0 {
302			visibleHeight += l.gap
303		}
304
305		if visibleHeight >= l.height {
306			break
307		}
308		currentIdx++
309	}
310
311	endIdx = currentIdx
312	if endIdx >= len(l.items) {
313		endIdx = len(l.items) - 1
314	}
315
316	return startIdx, endIdx
317}
318
319// Render renders the list and returns the visible lines.
320func (l *List) Render() string {
321	if len(l.items) == 0 {
322		return ""
323	}
324
325	var lines []string
326	currentIdx := l.offsetIdx
327	currentOffset := l.offsetLine
328
329	linesNeeded := l.height
330
331	for linesNeeded > 0 && currentIdx < len(l.items) {
332		item := l.renderItem(currentIdx, true)
333		itemLines := strings.Split(item.content, "\n")
334		itemHeight := len(itemLines)
335
336		if currentOffset < itemHeight {
337			// Add visible content lines
338			lines = append(lines, itemLines[currentOffset:]...)
339
340			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
341			// But in the loop we can just add it and trim later
342			if l.gap > 0 {
343				for i := 0; i < l.gap; i++ {
344					lines = append(lines, "")
345				}
346			}
347		} else {
348			// offsetLine starts in the gap
349			gapOffset := currentOffset - itemHeight
350			gapRemaining := l.gap - gapOffset
351			if gapRemaining > 0 {
352				for range gapRemaining {
353					lines = append(lines, "")
354				}
355			}
356		}
357
358		linesNeeded = l.height - len(lines)
359		currentIdx++
360		currentOffset = 0 // Reset offset for subsequent items
361	}
362
363	if len(lines) > l.height {
364		lines = lines[:l.height]
365	}
366
367	return strings.Join(lines, "\n")
368}
369
370// PrependItems prepends items to the list.
371func (l *List) PrependItems(items ...Item) {
372	l.items = append(items, l.items...)
373
374	// Shift cache
375	newCache := make(map[int]renderedItem)
376	for idx, val := range l.renderedItems {
377		newCache[idx+len(items)] = val
378	}
379	l.renderedItems = newCache
380
381	// Keep view position relative to the content that was visible
382	l.offsetIdx += len(items)
383
384	// Update selection index if valid
385	if l.selectedIdx != -1 {
386		l.selectedIdx += len(items)
387	}
388}
389
390// AppendItems appends items to the list.
391func (l *List) AppendItems(items ...Item) {
392	l.items = append(l.items, items...)
393}
394
395// Focus sets the focus state of the list.
396func (l *List) Focus() {
397	l.focused = true
398}
399
400// Blur removes the focus state from the list.
401func (l *List) Blur() {
402	l.focused = false
403}
404
405// ScrollToTop scrolls the list to the top.
406func (l *List) ScrollToTop() {
407	l.offsetIdx = 0
408	l.offsetLine = 0
409}
410
411// ScrollToBottom scrolls the list to the bottom.
412func (l *List) ScrollToBottom() {
413	if len(l.items) == 0 {
414		return
415	}
416
417	// Scroll to the last item
418	var totalHeight int
419	for i := len(l.items) - 1; i >= 0; i-- {
420		item := l.getItem(i)
421		totalHeight += item.height
422		if l.gap > 0 && i < len(l.items)-1 {
423			totalHeight += l.gap
424		}
425		if totalHeight >= l.height {
426			l.offsetIdx = i
427			l.offsetLine = totalHeight - l.height
428			break
429		}
430	}
431	if totalHeight < l.height {
432		// All items fit in the viewport
433		l.ScrollToTop()
434	}
435}
436
437// ScrollToSelected scrolls the list to the selected item.
438func (l *List) ScrollToSelected() {
439	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
440		return
441	}
442
443	startIdx, endIdx := l.findVisibleItems()
444	if l.selectedIdx < startIdx {
445		// Selected item is above the visible range
446		l.offsetIdx = l.selectedIdx
447		l.offsetLine = 0
448	} else if l.selectedIdx > endIdx {
449		// Selected item is below the visible range
450		// Scroll so that the selected item is at the bottom
451		var totalHeight int
452		for i := l.selectedIdx; i >= 0; i-- {
453			item := l.getItem(i)
454			totalHeight += item.height
455			if l.gap > 0 && i < l.selectedIdx {
456				totalHeight += l.gap
457			}
458			if totalHeight >= l.height {
459				l.offsetIdx = i
460				l.offsetLine = totalHeight - l.height
461				break
462			}
463		}
464		if totalHeight < l.height {
465			// All items fit in the viewport
466			l.ScrollToTop()
467		}
468	}
469}
470
471// SelectedItemInView returns whether the selected item is currently in view.
472func (l *List) SelectedItemInView() bool {
473	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
474		return false
475	}
476	startIdx, endIdx := l.findVisibleItems()
477	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
478}
479
480// SetSelected sets the selected item index in the list.
481func (l *List) SetSelected(index int) {
482	if index < 0 || index >= len(l.items) {
483		l.selectedIdx = -1
484	} else {
485		l.selectedIdx = index
486	}
487}
488
489// SelectPrev selects the previous item in the list.
490func (l *List) SelectPrev() {
491	if l.selectedIdx > 0 {
492		l.selectedIdx--
493	}
494}
495
496// SelectNext selects the next item in the list.
497func (l *List) SelectNext() {
498	if l.selectedIdx < len(l.items)-1 {
499		l.selectedIdx++
500	}
501}
502
503// SelectFirst selects the first item in the list.
504func (l *List) SelectFirst() {
505	if len(l.items) > 0 {
506		l.selectedIdx = 0
507	}
508}
509
510// SelectLast selects the last item in the list.
511func (l *List) SelectLast() {
512	if len(l.items) > 0 {
513		l.selectedIdx = len(l.items) - 1
514	}
515}
516
517// SelectFirstInView selects the first item currently in view.
518func (l *List) SelectFirstInView() {
519	startIdx, _ := l.findVisibleItems()
520	l.selectedIdx = startIdx
521}
522
523// SelectLastInView selects the last item currently in view.
524func (l *List) SelectLastInView() {
525	_, endIdx := l.findVisibleItems()
526	l.selectedIdx = endIdx
527}
528
529// HandleMouseDown handles mouse down events at the given line in the viewport.
530// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
531// Returns true if the event was handled.
532func (l *List) HandleMouseDown(x, y int) bool {
533	if len(l.items) == 0 {
534		return false
535	}
536
537	// Find which item was clicked
538	itemIdx, itemY := l.findItemAtY(x, y)
539	if itemIdx < 0 {
540		return false
541	}
542
543	l.mouseDown = true
544	l.mouseDownItem = itemIdx
545	l.mouseDownX = x
546	l.mouseDownY = itemY
547	l.mouseDragItem = itemIdx
548	l.mouseDragX = x
549	l.mouseDragY = itemY
550
551	// Select the clicked item
552	l.SetSelected(itemIdx)
553
554	if clickable, ok := l.items[itemIdx].(MouseClickable); ok {
555		clickable.HandleMouseClick(ansi.MouseButton1, x, itemY)
556		l.items[itemIdx] = clickable.(Item)
557		l.invalidateItem(itemIdx)
558	}
559
560	return true
561}
562
563// HandleMouseUp handles mouse up events at the given line in the viewport.
564// Returns true if the event was handled.
565func (l *List) HandleMouseUp(x, y int) bool {
566	if !l.mouseDown {
567		return false
568	}
569
570	l.mouseDown = false
571
572	return true
573}
574
575// HandleMouseDrag handles mouse drag events at the given line in the viewport.
576// x and y are viewport-relative coordinates.
577// Returns true if the event was handled.
578func (l *List) HandleMouseDrag(x, y int) bool {
579	if !l.mouseDown {
580		return false
581	}
582
583	if len(l.items) == 0 {
584		return false
585	}
586
587	// Find which item we're dragging over
588	itemIdx, itemY := l.findItemAtY(x, y)
589	if itemIdx < 0 {
590		return false
591	}
592
593	l.mouseDragItem = itemIdx
594	l.mouseDragX = x
595	l.mouseDragY = itemY
596
597	return true
598}
599
600// ClearHighlight clears any active text highlighting.
601func (l *List) ClearHighlight() {
602	l.mouseDownItem = -1
603	l.mouseDragItem = -1
604	l.lastHighlighted = make(map[int]bool)
605}
606
607// findItemAtY finds the item at the given viewport y coordinate.
608// Returns the item index and the y offset within that item. It returns -1, -1
609// if no item is found.
610func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
611	if y < 0 || y >= l.height {
612		return -1, -1
613	}
614
615	// Walk through visible items to find which one contains this y
616	currentIdx := l.offsetIdx
617	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
618
619	for currentIdx < len(l.items) && currentLine < l.height {
620		item := l.getItem(currentIdx)
621		itemEndLine := currentLine + item.height
622
623		// Check if y is within this item's visible range
624		if y >= currentLine && y < itemEndLine {
625			// Found the item, calculate itemY (offset within the item)
626			itemY = y - currentLine
627			return currentIdx, itemY
628		}
629
630		// Move to next item
631		currentLine = itemEndLine
632		if l.gap > 0 {
633			currentLine += l.gap
634		}
635		currentIdx++
636	}
637
638	return -1, -1
639}
640
641// getHighlightRange returns the current highlight range.
642func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
643	if l.mouseDownItem < 0 {
644		return -1, -1, -1, -1, -1, -1
645	}
646
647	downItemIdx := l.mouseDownItem
648	dragItemIdx := l.mouseDragItem
649
650	// Determine selection direction
651	draggingDown := dragItemIdx > downItemIdx ||
652		(dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
653		(dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
654
655	if draggingDown {
656		// Normal forward selection
657		startItemIdx = downItemIdx
658		startLine = l.mouseDownY
659		startCol = l.mouseDownX
660		endItemIdx = dragItemIdx
661		endLine = l.mouseDragY
662		endCol = l.mouseDragX
663	} else {
664		// Backward selection (dragging up)
665		startItemIdx = dragItemIdx
666		startLine = l.mouseDragY
667		startCol = l.mouseDragX
668		endItemIdx = downItemIdx
669		endLine = l.mouseDownY
670		endCol = l.mouseDownX
671	}
672
673	return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
674}
675
676// countLines counts the number of lines in a string.
677func countLines(s string) int {
678	if s == "" {
679		return 0
680	}
681	return strings.Count(s, "\n") + 1
682}