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// 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}