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