list.go

  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}