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}