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}