list.go

  1package list
  2
  3import (
  4	"strings"
  5)
  6
  7// List represents a list of items that can be lazily rendered. A list is
  8// always rendered like a chat conversation where items are stacked vertically
  9// from top to bottom.
 10type List struct {
 11	// Viewport size
 12	width, height int
 13
 14	// Items in the list
 15	items []Item
 16
 17	// Gap between items (0 or less means no gap)
 18	gap int
 19
 20	// show list in reverse order
 21	reverse bool
 22
 23	// Focus and selection state
 24	focused     bool
 25	selectedIdx int // The current selected index -1 means no selection
 26
 27	// offsetIdx is the index of the first visible item in the viewport.
 28	offsetIdx int
 29	// offsetLine is the number of lines of the item at offsetIdx that are
 30	// scrolled out of view (above the viewport).
 31	// It must always be >= 0.
 32	offsetLine int
 33
 34	// renderCallbacks is a list of callbacks to apply when rendering items.
 35	renderCallbacks []func(idx, selectedIdx int, item Item) Item
 36
 37	// cache is the F6 list-level render memo, keyed by item pointer.
 38	// Each entry stores the rendered content, a pre-split slice of
 39	// lines (so AtBottom / Render / VisibleItemIndices /
 40	// findItemAtY all share one render per frame), the height, and
 41	// the keys that govern invalidation (width and version). The
 42	// frozen flag mirrors ยง4.5.1: once a Finished() item is
 43	// rendered, subsequent draws return the stored output verbatim
 44	// without calling back into Render.
 45	cache map[Item]*listCacheEntry
 46
 47	// freezeSuppressed marks items the list must not freeze on the
 48	// next render even when their Finished() reports true. This is
 49	// the ยง4.5.1 selection-drag escape hatch (option (a)): items
 50	// inside an active selection range render as live items so that
 51	// per-line highlight overlays land on the latest content. Cleared
 52	// on EndSelectionDrag.
 53	freezeSuppressed map[Item]struct{}
 54}
 55
 56// listCacheEntry is the per-item entry in the list-level render memo.
 57type listCacheEntry struct {
 58	width   int
 59	version uint64
 60	frozen  bool
 61	content string
 62	lines   []string
 63	height  int
 64}
 65
 66// renderedItem is the legacy view of a cached entry returned by getItem.
 67// Internal callers that don't need the line slice keep using this
 68// shape; functions that walk lines (Render) take the slice off the
 69// cache entry directly.
 70type renderedItem struct {
 71	content string
 72	height  int
 73}
 74
 75// NewList creates a new lazy-loaded list.
 76func NewList(items ...Item) *List {
 77	l := new(List)
 78	l.items = items
 79	l.selectedIdx = -1
 80	l.cache = make(map[Item]*listCacheEntry)
 81	l.freezeSuppressed = make(map[Item]struct{})
 82	return l
 83}
 84
 85// RenderCallback defines a function that can modify an item before it is
 86// rendered.
 87type RenderCallback func(idx, selectedIdx int, item Item) Item
 88
 89// RegisterRenderCallback registers a callback to be called when rendering
 90// items. This can be used to modify items before they are rendered.
 91func (l *List) RegisterRenderCallback(cb RenderCallback) {
 92	l.renderCallbacks = append(l.renderCallbacks, cb)
 93}
 94
 95// SetSize sets the size of the list viewport. A width change drops the
 96// entire render cache because every entry's wrapped output depends on
 97// width; a height-only change is a no-op for the cache.
 98func (l *List) SetSize(width, height int) {
 99	if l.width != width {
100		l.invalidateAll()
101	}
102	l.width = width
103	l.height = height
104}
105
106// SetGap sets the gap between items.
107func (l *List) SetGap(gap int) {
108	l.gap = gap
109}
110
111// Gap returns the gap between items.
112func (l *List) Gap() int {
113	return l.gap
114}
115
116// AtBottom returns whether the list is showing the last item at the bottom.
117func (l *List) AtBottom() bool {
118	if len(l.items) == 0 {
119		return true
120	}
121
122	// Calculate the height from offsetIdx to the end.
123	var totalHeight int
124	for idx := l.offsetIdx; idx < len(l.items); idx++ {
125		if totalHeight > l.height {
126			// No need to calculate further, we're already past the viewport height
127			return false
128		}
129		item := l.getItem(idx)
130		itemHeight := item.height
131		if l.gap > 0 && idx > l.offsetIdx {
132			itemHeight += l.gap
133		}
134		totalHeight += itemHeight
135	}
136
137	return totalHeight-l.offsetLine <= l.height
138}
139
140// SetReverse shows the list in reverse order.
141func (l *List) SetReverse(reverse bool) {
142	l.reverse = reverse
143}
144
145// Width returns the width of the list viewport.
146func (l *List) Width() int {
147	return l.width
148}
149
150// Height returns the height of the list viewport.
151func (l *List) Height() int {
152	return l.height
153}
154
155// Len returns the number of items in the list.
156func (l *List) Len() int {
157	return len(l.items)
158}
159
160// lastOffsetItem returns the index and line offsets of the last item that can
161// be partially visible in the viewport.
162func (l *List) lastOffsetItem() (int, int, int) {
163	var totalHeight int
164	var idx int
165	for idx = len(l.items) - 1; idx >= 0; idx-- {
166		item := l.getItem(idx)
167		itemHeight := item.height
168		if l.gap > 0 && idx < len(l.items)-1 {
169			itemHeight += l.gap
170		}
171		totalHeight += itemHeight
172		if totalHeight > l.height {
173			break
174		}
175	}
176
177	// Calculate line offset within the item
178	lineOffset := max(totalHeight-l.height, 0)
179	idx = max(idx, 0)
180
181	return idx, lineOffset, totalHeight
182}
183
184// getItem renders (if needed) and returns the item at the given index.
185// The result is served from the F6 cache when possible โ€” see
186// renderItemEntry for the cache-key semantics.
187func (l *List) getItem(idx int) renderedItem {
188	if idx < 0 || idx >= len(l.items) {
189		return renderedItem{}
190	}
191	entry := l.renderItemEntry(idx)
192	if entry == nil {
193		return renderedItem{}
194	}
195	return renderedItem{content: entry.content, height: entry.height}
196}
197
198// renderItemEntry returns the cache entry for the given index, populating
199// the cache on miss. The result must not be retained past the next
200// invalidation (SetSize width change, SetItems, etc.).
201//
202// Render callbacks always run, even for frozen entries: callbacks
203// are how the list discovers per-frame state changes (selection,
204// highlight range) and they bump the item's version when those
205// changes affect the rendered output. A frozen item whose callback
206// run is a no-op (same focus, same highlight) keeps its stored
207// version and the cache hit is preserved on the post-callback
208// version check.
209func (l *List) renderItemEntry(idx int) *listCacheEntry {
210	if idx < 0 || idx >= len(l.items) {
211		return nil
212	}
213
214	rawItem := l.items[idx]
215	entry := l.cache[rawItem]
216
217	// Run render callbacks. Callbacks may mutate the item (focus,
218	// highlight) which in turn bumps its version when state actually
219	// changes. We capture the post-callback version below.
220	item := rawItem
221	if len(l.renderCallbacks) > 0 {
222		for _, cb := range l.renderCallbacks {
223			if it := cb(idx, l.selectedIdx, item); it != nil {
224				item = it
225			}
226		}
227	}
228
229	version := rawItem.Version()
230	if entry != nil && entry.width == l.width && entry.version == version {
231		// Cache hit โ€” frozen or unfrozen, the entry content is
232		// still correct because no version bump landed since the
233		// last render. Selection-drag suppression turns this into
234		// a miss only if the entry is frozen.
235		if !entry.frozen {
236			return entry
237		}
238		if _, suppressed := l.freezeSuppressed[rawItem]; !suppressed {
239			return entry
240		}
241	}
242
243	rendered := item.Render(l.width)
244	rendered = strings.TrimRight(rendered, "\n")
245	lines := strings.Split(rendered, "\n")
246	height := len(lines)
247
248	// Re-read the version after Render so that any version bumps
249	// caused by Render itself (e.g. an item that mutates internal
250	// state during rendering) are captured. Without this we would
251	// freeze a stale entry under the post-render version.
252	finalVersion := rawItem.Version()
253
254	frozen := false
255	if rawItem.Finished() {
256		if _, suppressed := l.freezeSuppressed[rawItem]; !suppressed {
257			frozen = true
258		}
259	}
260
261	if entry == nil {
262		entry = &listCacheEntry{}
263		l.cache[rawItem] = entry
264	}
265	entry.width = l.width
266	entry.version = finalVersion
267	entry.frozen = frozen
268	entry.content = rendered
269	entry.lines = lines
270	entry.height = height
271	return entry
272}
273
274// invalidateAll drops every cache entry. Called on width changes.
275func (l *List) invalidateAll() {
276	for k := range l.cache {
277		delete(l.cache, k)
278	}
279}
280
281// Invalidate drops the cache entry for the given item, forcing a
282// re-render on the next getItem call. No-op if the item is not in
283// the cache.
284func (l *List) Invalidate(item Item) {
285	delete(l.cache, item)
286}
287
288// InvalidateFrozen drops the frozen flag (and stored content) for the
289// given item. Equivalent to Invalidate but exposed under the F6
290// frozen-items vocabulary so external callers can express intent.
291func (l *List) InvalidateFrozen(item Item) {
292	delete(l.cache, item)
293}
294
295// retainCacheFor drops every cache entry whose key is not in the given
296// item set. Used by SetItems to keep entries for stable items while
297// dropping entries for removed ones.
298func (l *List) retainCacheFor(items []Item) {
299	if len(l.cache) == 0 {
300		return
301	}
302	keep := make(map[Item]struct{}, len(items))
303	for _, it := range items {
304		keep[it] = struct{}{}
305	}
306	for k := range l.cache {
307		if _, ok := keep[k]; !ok {
308			delete(l.cache, k)
309		}
310	}
311}
312
313// BeginSelectionDrag marks the items in the inclusive [startIdx, endIdx]
314// range as un-freezable for the duration of an active selection drag.
315// Frozen entries inside the range are dropped so the next render
316// reflects live selection-overlay output. The corresponding
317// EndSelectionDrag clears the suppression set and lets items
318// re-freeze on their next render. Indices outside the items slice
319// are clipped silently.
320func (l *List) BeginSelectionDrag(startIdx, endIdx int) {
321	if len(l.items) == 0 {
322		return
323	}
324	if startIdx > endIdx {
325		startIdx, endIdx = endIdx, startIdx
326	}
327	startIdx = max(startIdx, 0)
328	endIdx = min(endIdx, len(l.items)-1)
329	for i := startIdx; i <= endIdx; i++ {
330		it := l.items[i]
331		l.freezeSuppressed[it] = struct{}{}
332		// Drop any cached frozen entry so the next render rebuilds
333		// it as a live (un-frozen) entry that picks up the
334		// selection overlay.
335		if entry, ok := l.cache[it]; ok && entry.frozen {
336			delete(l.cache, it)
337		}
338	}
339}
340
341// EndSelectionDrag clears the selection-drag freeze suppression. Items
342// inside the previous range will re-freeze on their next render once
343// their Finished() reports true again.
344func (l *List) EndSelectionDrag() {
345	for k := range l.freezeSuppressed {
346		delete(l.freezeSuppressed, k)
347		// Drop the cache entry so the next render produces a clean
348		// (un-highlighted) frozen entry.
349		delete(l.cache, k)
350	}
351}
352
353// ScrollToIndex scrolls the list to the given item index.
354func (l *List) ScrollToIndex(index int) {
355	if index < 0 {
356		index = 0
357	}
358	if index >= len(l.items) {
359		index = len(l.items) - 1
360	}
361	l.offsetIdx = index
362	l.offsetLine = 0
363}
364
365// ScrollBy scrolls the list by the given number of lines.
366func (l *List) ScrollBy(lines int) {
367	if len(l.items) == 0 || lines == 0 {
368		return
369	}
370
371	if l.reverse {
372		lines = -lines
373	}
374
375	if lines > 0 {
376		if l.AtBottom() {
377			// Already at bottom
378			return
379		}
380
381		// Scroll down
382		l.offsetLine += lines
383		currentItem := l.getItem(l.offsetIdx)
384		for l.offsetLine >= currentItem.height {
385			l.offsetLine -= currentItem.height
386			if l.gap > 0 {
387				l.offsetLine = max(0, l.offsetLine-l.gap)
388			}
389
390			// Move to next item
391			l.offsetIdx++
392			if l.offsetIdx > len(l.items)-1 {
393				// Reached bottom
394				l.ScrollToBottom()
395				return
396			}
397			currentItem = l.getItem(l.offsetIdx)
398		}
399
400		lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
401		if l.offsetIdx > lastOffsetIdx || (l.offsetIdx == lastOffsetIdx && l.offsetLine > lastOffsetLine) {
402			// Clamp to bottom
403			l.offsetIdx = lastOffsetIdx
404			l.offsetLine = lastOffsetLine
405		}
406	} else if lines < 0 {
407		// Scroll up
408		l.offsetLine += lines // lines is negative
409		for l.offsetLine < 0 {
410			// Move to previous item
411			l.offsetIdx--
412			if l.offsetIdx < 0 {
413				// Reached top
414				l.ScrollToTop()
415				break
416			}
417			prevItem := l.getItem(l.offsetIdx)
418			totalHeight := prevItem.height
419			if l.gap > 0 {
420				totalHeight += l.gap
421			}
422			l.offsetLine += totalHeight
423		}
424	}
425}
426
427// VisibleItemIndices finds the range of items that are visible in the viewport.
428// This is used for checking if selected item is in view.
429func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
430	if len(l.items) == 0 {
431		return 0, 0
432	}
433
434	startIdx = l.offsetIdx
435	currentIdx := startIdx
436	visibleHeight := -l.offsetLine
437
438	for currentIdx < len(l.items) {
439		item := l.getItem(currentIdx)
440		visibleHeight += item.height
441		if l.gap > 0 {
442			visibleHeight += l.gap
443		}
444
445		if visibleHeight >= l.height {
446			break
447		}
448		currentIdx++
449	}
450
451	endIdx = currentIdx
452	if endIdx >= len(l.items) {
453		endIdx = len(l.items) - 1
454	}
455
456	return startIdx, endIdx
457}
458
459// Render renders the list and returns the visible lines.
460//
461// F7: per-item slicing is bounded by the remaining viewport budget so
462// per-frame work is O(viewport) rather than O(total item heights).
463// We never append beyond l.height lines to the output buffer; the
464// final trim is therefore unnecessary. Reverse mode applies the same
465// final reversal as before, which is byte-identical because the
466// pre-F7 trim happened at the tail of the joined buffer (the same
467// lines we now drop implicitly per item).
468func (l *List) Render() string {
469	if len(l.items) == 0 {
470		return ""
471	}
472
473	budget := max(l.height, 0)
474	lines := make([]string, 0, budget)
475	currentIdx := l.offsetIdx
476	currentOffset := l.offsetLine
477
478	for currentIdx < len(l.items) {
479		remaining := budget - len(lines)
480		if remaining <= 0 {
481			break
482		}
483
484		entry := l.renderItemEntry(currentIdx)
485		if entry == nil {
486			break
487		}
488		itemLines := entry.lines
489		itemHeight := len(itemLines)
490
491		if currentOffset >= 0 && currentOffset < itemHeight {
492			// Append only the visible slice that fits in the
493			// remaining viewport budget. Anything past the
494			// budget would be discarded by the pre-F7 tail
495			// trim, so skipping the append here is
496			// byte-identical and bounded.
497			visible := itemLines[currentOffset:]
498			if len(visible) > remaining {
499				visible = visible[:remaining]
500			}
501			lines = append(lines, visible...)
502
503			// Gap rows after the item, capped to the
504			// remaining budget so a 30k-line item with a
505			// trailing gap can't push past the viewport.
506			if l.gap > 0 {
507				gapBudget := min(budget-len(lines), l.gap)
508				for range gapBudget {
509					lines = append(lines, "")
510				}
511			}
512		} else {
513			// offsetLine starts inside the gap.
514			gapOffset := currentOffset - itemHeight
515			gapRemaining := l.gap - gapOffset
516			if gapRemaining > 0 {
517				gapBudget := min(budget-len(lines), gapRemaining)
518				for range gapBudget {
519					lines = append(lines, "")
520				}
521			}
522		}
523
524		currentIdx++
525		currentOffset = 0 // Reset offset for subsequent items.
526	}
527
528	l.height = budget
529
530	if l.reverse {
531		// Reverse the lines so the list renders bottom-to-top.
532		for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
533			lines[i], lines[j] = lines[j], lines[i]
534		}
535	}
536
537	return strings.Join(lines, "\n")
538}
539
540// PrependItems prepends items to the list.
541func (l *List) PrependItems(items ...Item) {
542	l.items = append(items, l.items...)
543
544	// Keep view position relative to the content that was visible
545	l.offsetIdx += len(items)
546
547	// Update selection index if valid
548	if l.selectedIdx != -1 {
549		l.selectedIdx += len(items)
550	}
551}
552
553// SetItems sets the items in the list. Cache entries for items that
554// remain after the swap are preserved; entries for removed items are
555// dropped.
556func (l *List) SetItems(items ...Item) {
557	l.items = items
558	l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
559	l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
560	l.offsetLine = 0
561	l.retainCacheFor(items)
562}
563
564// AppendItems appends items to the list.
565func (l *List) AppendItems(items ...Item) {
566	l.items = append(l.items, items...)
567}
568
569// RemoveItem removes the item at the given index from the list.
570func (l *List) RemoveItem(idx int) {
571	if idx < 0 || idx >= len(l.items) {
572		return
573	}
574
575	removed := l.items[idx]
576
577	// Remove the item
578	l.items = append(l.items[:idx], l.items[idx+1:]...)
579
580	// Drop the cache entry for the removed item; entries for stable
581	// items stay valid because they are keyed by pointer, not index.
582	delete(l.cache, removed)
583	delete(l.freezeSuppressed, removed)
584
585	// Adjust selection if needed
586	if l.selectedIdx == idx {
587		l.selectedIdx = -1
588	} else if l.selectedIdx > idx {
589		l.selectedIdx--
590	}
591
592	// Adjust offset if needed
593	if l.offsetIdx > idx {
594		l.offsetIdx--
595	} else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
596		l.offsetIdx = max(0, len(l.items)-1)
597		l.offsetLine = 0
598	}
599}
600
601// Focused returns whether the list is focused.
602func (l *List) Focused() bool {
603	return l.focused
604}
605
606// Focus sets the focus state of the list.
607func (l *List) Focus() {
608	l.focused = true
609}
610
611// Blur removes the focus state from the list.
612func (l *List) Blur() {
613	l.focused = false
614}
615
616// ScrollToTop scrolls the list to the top.
617func (l *List) ScrollToTop() {
618	l.offsetIdx = 0
619	l.offsetLine = 0
620}
621
622// ScrollToBottom scrolls the list to the bottom.
623func (l *List) ScrollToBottom() {
624	if len(l.items) == 0 {
625		return
626	}
627
628	lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
629	l.offsetIdx = lastOffsetIdx
630	l.offsetLine = lastOffsetLine
631}
632
633// ScrollToSelected scrolls the list to the selected item.
634func (l *List) ScrollToSelected() {
635	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
636		return
637	}
638
639	startIdx, endIdx := l.VisibleItemIndices()
640	if l.selectedIdx < startIdx {
641		// Selected item is above the visible range
642		l.offsetIdx = l.selectedIdx
643		l.offsetLine = 0
644	} else if l.selectedIdx > endIdx {
645		// Selected item is below the visible range
646		// Scroll so that the selected item is at the bottom
647		var totalHeight int
648		for i := l.selectedIdx; i >= 0; i-- {
649			item := l.getItem(i)
650			totalHeight += item.height
651			if l.gap > 0 && i < l.selectedIdx {
652				totalHeight += l.gap
653			}
654			if totalHeight >= l.height {
655				l.offsetIdx = i
656				l.offsetLine = totalHeight - l.height
657				break
658			}
659		}
660		if totalHeight < l.height {
661			// All items fit in the viewport
662			l.ScrollToTop()
663		}
664	}
665}
666
667// SelectedItemInView returns whether the selected item is currently in view.
668func (l *List) SelectedItemInView() bool {
669	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
670		return false
671	}
672	startIdx, endIdx := l.VisibleItemIndices()
673	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
674}
675
676// SetSelected sets the selected item index in the list.
677// It returns -1 if the index is out of bounds.
678func (l *List) SetSelected(index int) {
679	if index < 0 || index >= len(l.items) {
680		l.selectedIdx = -1
681	} else {
682		l.selectedIdx = index
683	}
684}
685
686// Selected returns the index of the currently selected item. It returns -1 if
687// no item is selected.
688func (l *List) Selected() int {
689	return l.selectedIdx
690}
691
692// IsSelectedFirst returns whether the first item is selected.
693func (l *List) IsSelectedFirst() bool {
694	return l.selectedIdx == 0
695}
696
697// IsSelectedLast returns whether the last item is selected.
698func (l *List) IsSelectedLast() bool {
699	return l.selectedIdx == len(l.items)-1
700}
701
702// SelectPrev selects the visually previous item (moves toward visual top).
703// It returns whether the selection changed.
704func (l *List) SelectPrev() bool {
705	if l.reverse {
706		// In reverse, visual up = higher index
707		if l.selectedIdx < len(l.items)-1 {
708			l.selectedIdx++
709			return true
710		}
711	} else {
712		// Normal: visual up = lower index
713		if l.selectedIdx > 0 {
714			l.selectedIdx--
715			return true
716		}
717	}
718	return false
719}
720
721// SelectNext selects the next item in the list.
722// It returns whether the selection changed.
723func (l *List) SelectNext() bool {
724	if l.reverse {
725		// In reverse, visual down = lower index
726		if l.selectedIdx > 0 {
727			l.selectedIdx--
728			return true
729		}
730	} else {
731		// Normal: visual down = higher index
732		if l.selectedIdx < len(l.items)-1 {
733			l.selectedIdx++
734			return true
735		}
736	}
737	return false
738}
739
740// SelectFirst selects the first item in the list.
741// It returns whether the selection changed.
742func (l *List) SelectFirst() bool {
743	if len(l.items) == 0 {
744		return false
745	}
746	l.selectedIdx = 0
747	return true
748}
749
750// SelectLast selects the last item in the list (highest index).
751// It returns whether the selection changed.
752func (l *List) SelectLast() bool {
753	if len(l.items) == 0 {
754		return false
755	}
756	l.selectedIdx = len(l.items) - 1
757	return true
758}
759
760// WrapToStart wraps selection to the visual start (for circular navigation).
761// In normal mode, this is index 0. In reverse mode, this is the highest index.
762func (l *List) WrapToStart() bool {
763	if len(l.items) == 0 {
764		return false
765	}
766	if l.reverse {
767		l.selectedIdx = len(l.items) - 1
768	} else {
769		l.selectedIdx = 0
770	}
771	return true
772}
773
774// WrapToEnd wraps selection to the visual end (for circular navigation).
775// In normal mode, this is the highest index. In reverse mode, this is index 0.
776func (l *List) WrapToEnd() bool {
777	if len(l.items) == 0 {
778		return false
779	}
780	if l.reverse {
781		l.selectedIdx = 0
782	} else {
783		l.selectedIdx = len(l.items) - 1
784	}
785	return true
786}
787
788// SelectedItem returns the currently selected item. It may be nil if no item
789// is selected.
790func (l *List) SelectedItem() Item {
791	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
792		return nil
793	}
794	return l.items[l.selectedIdx]
795}
796
797// SelectFirstInView selects the first item currently in view.
798func (l *List) SelectFirstInView() {
799	startIdx, _ := l.VisibleItemIndices()
800	l.selectedIdx = startIdx
801}
802
803// SelectLastInView selects the last item currently in view.
804func (l *List) SelectLastInView() {
805	_, endIdx := l.VisibleItemIndices()
806	l.selectedIdx = endIdx
807}
808
809// ItemAt returns the item at the given index.
810func (l *List) ItemAt(index int) Item {
811	if index < 0 || index >= len(l.items) {
812		return nil
813	}
814	return l.items[index]
815}
816
817// ItemIndexAtPosition returns the item at the given viewport-relative y
818// coordinate. Returns the item index and the y offset within that item. It
819// returns -1, -1 if no item is found.
820func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
821	return l.findItemAtY(x, y)
822}
823
824// findItemAtY finds the item at the given viewport y coordinate.
825// Returns the item index and the y offset within that item. It returns -1, -1
826// if no item is found.
827func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
828	if y < 0 || y >= l.height {
829		return -1, -1
830	}
831
832	// Walk through visible items to find which one contains this y
833	currentIdx := l.offsetIdx
834	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
835
836	for currentIdx < len(l.items) && currentLine < l.height {
837		item := l.getItem(currentIdx)
838		itemEndLine := currentLine + item.height
839
840		// Check if y is within this item's visible range
841		if y >= currentLine && y < itemEndLine {
842			// Found the item, calculate itemY (offset within the item)
843			itemY = y - currentLine
844			return currentIdx, itemY
845		}
846
847		// Move to next item
848		currentLine = itemEndLine
849		if l.gap > 0 {
850			currentLine += l.gap
851		}
852		currentIdx++
853	}
854
855	return -1, -1
856}