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}