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.
460func (l *List) Render() string {
461	if len(l.items) == 0 {
462		return ""
463	}
464
465	var lines []string
466	currentIdx := l.offsetIdx
467	currentOffset := l.offsetLine
468
469	linesNeeded := l.height
470
471	for linesNeeded > 0 && currentIdx < len(l.items) {
472		entry := l.renderItemEntry(currentIdx)
473		if entry == nil {
474			break
475		}
476		itemLines := entry.lines
477		itemHeight := len(itemLines)
478
479		if currentOffset >= 0 && currentOffset < itemHeight {
480			// Add visible content lines
481			lines = append(lines, itemLines[currentOffset:]...)
482
483			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
484			// But in the loop we can just add it and trim later
485			if l.gap > 0 {
486				for i := 0; i < l.gap; i++ {
487					lines = append(lines, "")
488				}
489			}
490		} else {
491			// offsetLine starts in the gap
492			gapOffset := currentOffset - itemHeight
493			gapRemaining := l.gap - gapOffset
494			if gapRemaining > 0 {
495				for range gapRemaining {
496					lines = append(lines, "")
497				}
498			}
499		}
500
501		linesNeeded = l.height - len(lines)
502		currentIdx++
503		currentOffset = 0 // Reset offset for subsequent items
504	}
505
506	l.height = max(l.height, 0)
507
508	if len(lines) > l.height {
509		lines = lines[:l.height]
510	}
511
512	if l.reverse {
513		// Reverse the lines so the list renders bottom-to-top.
514		for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
515			lines[i], lines[j] = lines[j], lines[i]
516		}
517	}
518
519	return strings.Join(lines, "\n")
520}
521
522// PrependItems prepends items to the list.
523func (l *List) PrependItems(items ...Item) {
524	l.items = append(items, l.items...)
525
526	// Keep view position relative to the content that was visible
527	l.offsetIdx += len(items)
528
529	// Update selection index if valid
530	if l.selectedIdx != -1 {
531		l.selectedIdx += len(items)
532	}
533}
534
535// SetItems sets the items in the list. Cache entries for items that
536// remain after the swap are preserved; entries for removed items are
537// dropped.
538func (l *List) SetItems(items ...Item) {
539	l.items = items
540	l.selectedIdx = min(l.selectedIdx, len(l.items)-1)
541	l.offsetIdx = min(l.offsetIdx, len(l.items)-1)
542	l.offsetLine = 0
543	l.retainCacheFor(items)
544}
545
546// AppendItems appends items to the list.
547func (l *List) AppendItems(items ...Item) {
548	l.items = append(l.items, items...)
549}
550
551// RemoveItem removes the item at the given index from the list.
552func (l *List) RemoveItem(idx int) {
553	if idx < 0 || idx >= len(l.items) {
554		return
555	}
556
557	removed := l.items[idx]
558
559	// Remove the item
560	l.items = append(l.items[:idx], l.items[idx+1:]...)
561
562	// Drop the cache entry for the removed item; entries for stable
563	// items stay valid because they are keyed by pointer, not index.
564	delete(l.cache, removed)
565	delete(l.freezeSuppressed, removed)
566
567	// Adjust selection if needed
568	if l.selectedIdx == idx {
569		l.selectedIdx = -1
570	} else if l.selectedIdx > idx {
571		l.selectedIdx--
572	}
573
574	// Adjust offset if needed
575	if l.offsetIdx > idx {
576		l.offsetIdx--
577	} else if l.offsetIdx == idx && l.offsetIdx >= len(l.items) {
578		l.offsetIdx = max(0, len(l.items)-1)
579		l.offsetLine = 0
580	}
581}
582
583// Focused returns whether the list is focused.
584func (l *List) Focused() bool {
585	return l.focused
586}
587
588// Focus sets the focus state of the list.
589func (l *List) Focus() {
590	l.focused = true
591}
592
593// Blur removes the focus state from the list.
594func (l *List) Blur() {
595	l.focused = false
596}
597
598// ScrollToTop scrolls the list to the top.
599func (l *List) ScrollToTop() {
600	l.offsetIdx = 0
601	l.offsetLine = 0
602}
603
604// ScrollToBottom scrolls the list to the bottom.
605func (l *List) ScrollToBottom() {
606	if len(l.items) == 0 {
607		return
608	}
609
610	lastOffsetIdx, lastOffsetLine, _ := l.lastOffsetItem()
611	l.offsetIdx = lastOffsetIdx
612	l.offsetLine = lastOffsetLine
613}
614
615// ScrollToSelected scrolls the list to the selected item.
616func (l *List) ScrollToSelected() {
617	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
618		return
619	}
620
621	startIdx, endIdx := l.VisibleItemIndices()
622	if l.selectedIdx < startIdx {
623		// Selected item is above the visible range
624		l.offsetIdx = l.selectedIdx
625		l.offsetLine = 0
626	} else if l.selectedIdx > endIdx {
627		// Selected item is below the visible range
628		// Scroll so that the selected item is at the bottom
629		var totalHeight int
630		for i := l.selectedIdx; i >= 0; i-- {
631			item := l.getItem(i)
632			totalHeight += item.height
633			if l.gap > 0 && i < l.selectedIdx {
634				totalHeight += l.gap
635			}
636			if totalHeight >= l.height {
637				l.offsetIdx = i
638				l.offsetLine = totalHeight - l.height
639				break
640			}
641		}
642		if totalHeight < l.height {
643			// All items fit in the viewport
644			l.ScrollToTop()
645		}
646	}
647}
648
649// SelectedItemInView returns whether the selected item is currently in view.
650func (l *List) SelectedItemInView() bool {
651	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
652		return false
653	}
654	startIdx, endIdx := l.VisibleItemIndices()
655	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
656}
657
658// SetSelected sets the selected item index in the list.
659// It returns -1 if the index is out of bounds.
660func (l *List) SetSelected(index int) {
661	if index < 0 || index >= len(l.items) {
662		l.selectedIdx = -1
663	} else {
664		l.selectedIdx = index
665	}
666}
667
668// Selected returns the index of the currently selected item. It returns -1 if
669// no item is selected.
670func (l *List) Selected() int {
671	return l.selectedIdx
672}
673
674// IsSelectedFirst returns whether the first item is selected.
675func (l *List) IsSelectedFirst() bool {
676	return l.selectedIdx == 0
677}
678
679// IsSelectedLast returns whether the last item is selected.
680func (l *List) IsSelectedLast() bool {
681	return l.selectedIdx == len(l.items)-1
682}
683
684// SelectPrev selects the visually previous item (moves toward visual top).
685// It returns whether the selection changed.
686func (l *List) SelectPrev() bool {
687	if l.reverse {
688		// In reverse, visual up = higher index
689		if l.selectedIdx < len(l.items)-1 {
690			l.selectedIdx++
691			return true
692		}
693	} else {
694		// Normal: visual up = lower index
695		if l.selectedIdx > 0 {
696			l.selectedIdx--
697			return true
698		}
699	}
700	return false
701}
702
703// SelectNext selects the next item in the list.
704// It returns whether the selection changed.
705func (l *List) SelectNext() bool {
706	if l.reverse {
707		// In reverse, visual down = lower index
708		if l.selectedIdx > 0 {
709			l.selectedIdx--
710			return true
711		}
712	} else {
713		// Normal: visual down = higher index
714		if l.selectedIdx < len(l.items)-1 {
715			l.selectedIdx++
716			return true
717		}
718	}
719	return false
720}
721
722// SelectFirst selects the first item in the list.
723// It returns whether the selection changed.
724func (l *List) SelectFirst() bool {
725	if len(l.items) == 0 {
726		return false
727	}
728	l.selectedIdx = 0
729	return true
730}
731
732// SelectLast selects the last item in the list (highest index).
733// It returns whether the selection changed.
734func (l *List) SelectLast() bool {
735	if len(l.items) == 0 {
736		return false
737	}
738	l.selectedIdx = len(l.items) - 1
739	return true
740}
741
742// WrapToStart wraps selection to the visual start (for circular navigation).
743// In normal mode, this is index 0. In reverse mode, this is the highest index.
744func (l *List) WrapToStart() bool {
745	if len(l.items) == 0 {
746		return false
747	}
748	if l.reverse {
749		l.selectedIdx = len(l.items) - 1
750	} else {
751		l.selectedIdx = 0
752	}
753	return true
754}
755
756// WrapToEnd wraps selection to the visual end (for circular navigation).
757// In normal mode, this is the highest index. In reverse mode, this is index 0.
758func (l *List) WrapToEnd() bool {
759	if len(l.items) == 0 {
760		return false
761	}
762	if l.reverse {
763		l.selectedIdx = 0
764	} else {
765		l.selectedIdx = len(l.items) - 1
766	}
767	return true
768}
769
770// SelectedItem returns the currently selected item. It may be nil if no item
771// is selected.
772func (l *List) SelectedItem() Item {
773	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
774		return nil
775	}
776	return l.items[l.selectedIdx]
777}
778
779// SelectFirstInView selects the first item currently in view.
780func (l *List) SelectFirstInView() {
781	startIdx, _ := l.VisibleItemIndices()
782	l.selectedIdx = startIdx
783}
784
785// SelectLastInView selects the last item currently in view.
786func (l *List) SelectLastInView() {
787	_, endIdx := l.VisibleItemIndices()
788	l.selectedIdx = endIdx
789}
790
791// ItemAt returns the item at the given index.
792func (l *List) ItemAt(index int) Item {
793	if index < 0 || index >= len(l.items) {
794		return nil
795	}
796	return l.items[index]
797}
798
799// ItemIndexAtPosition returns the item at the given viewport-relative y
800// coordinate. Returns the item index and the y offset within that item. It
801// returns -1, -1 if no item is found.
802func (l *List) ItemIndexAtPosition(x, y int) (itemIdx int, itemY int) {
803	return l.findItemAtY(x, y)
804}
805
806// findItemAtY finds the item at the given viewport y coordinate.
807// Returns the item index and the y offset within that item. It returns -1, -1
808// if no item is found.
809func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) {
810	if y < 0 || y >= l.height {
811		return -1, -1
812	}
813
814	// Walk through visible items to find which one contains this y
815	currentIdx := l.offsetIdx
816	currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden
817
818	for currentIdx < len(l.items) && currentLine < l.height {
819		item := l.getItem(currentIdx)
820		itemEndLine := currentLine + item.height
821
822		// Check if y is within this item's visible range
823		if y >= currentLine && y < itemEndLine {
824			// Found the item, calculate itemY (offset within the item)
825			itemY = y - currentLine
826			return currentIdx, itemY
827		}
828
829		// Move to next item
830		currentLine = itemEndLine
831		if l.gap > 0 {
832			currentLine += l.gap
833		}
834		currentIdx++
835	}
836
837	return -1, -1
838}