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}