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}