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