simplelist.go

  1package list
  2
  3import (
  4	"strings"
  5
  6	tea "charm.land/bubbletea/v2"
  7	"charm.land/lipgloss/v2"
  8	uv "github.com/charmbracelet/ultraviolet"
  9	"github.com/charmbracelet/x/exp/ordered"
 10)
 11
 12const maxGapSize = 100
 13
 14var newlineBuffer = strings.Repeat("\n", maxGapSize)
 15
 16// SimpleList is a string-based list with virtual scrolling behavior.
 17// Based on exp/list but simplified for our needs.
 18type SimpleList struct {
 19	// Viewport dimensions.
 20	width, height int
 21
 22	// Scroll offset (in lines from top).
 23	offset int
 24
 25	// Items.
 26	items   []Item
 27	itemIDs map[string]int // ID -> index mapping
 28
 29	// Rendered content (all items stacked).
 30	rendered       string
 31	renderedHeight int   // Total height of rendered content in lines
 32	lineOffsets    []int // Byte offsets for each line (for fast slicing)
 33
 34	// Rendered item metadata.
 35	renderedItems map[string]renderedItem
 36
 37	// Selection.
 38	selectedIdx int
 39	focused     bool
 40
 41	// Focus tracking.
 42	prevSelectedIdx int
 43
 44	// Mouse/highlight state.
 45	mouseDown          bool
 46	mouseDownItem      int
 47	mouseDownX         int
 48	mouseDownY         int // viewport-relative Y
 49	mouseDragItem      int
 50	mouseDragX         int
 51	mouseDragY         int // viewport-relative Y
 52	selectionStartLine int
 53	selectionStartCol  int
 54	selectionEndLine   int
 55	selectionEndCol    int
 56
 57	// Configuration.
 58	gap int // Gap between items in lines
 59}
 60
 61type renderedItem struct {
 62	view   string
 63	height int
 64	start  int // Start line in rendered content
 65	end    int // End line in rendered content
 66}
 67
 68// NewSimpleList creates a new simple list.
 69func NewSimpleList(items ...Item) *SimpleList {
 70	l := &SimpleList{
 71		items:              items,
 72		itemIDs:            make(map[string]int, len(items)),
 73		renderedItems:      make(map[string]renderedItem),
 74		selectedIdx:        -1,
 75		prevSelectedIdx:    -1,
 76		gap:                0,
 77		selectionStartLine: -1,
 78		selectionStartCol:  -1,
 79		selectionEndLine:   -1,
 80		selectionEndCol:    -1,
 81	}
 82
 83	// Build ID map.
 84	for i, item := range items {
 85		if idItem, ok := item.(interface{ ID() string }); ok {
 86			l.itemIDs[idItem.ID()] = i
 87		}
 88	}
 89
 90	return l
 91}
 92
 93// Init initializes the list (Bubbletea lifecycle).
 94func (l *SimpleList) Init() tea.Cmd {
 95	return l.render()
 96}
 97
 98// Update handles messages (Bubbletea lifecycle).
 99func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) {
100	return l, nil
101}
102
103// View returns the visible viewport (Bubbletea lifecycle).
104func (l *SimpleList) View() string {
105	if l.height <= 0 || l.width <= 0 {
106		return ""
107	}
108
109	start, end := l.viewPosition()
110	viewStart := max(0, start)
111	viewEnd := end
112
113	if viewStart > viewEnd {
114		return ""
115	}
116
117	view := l.getLines(viewStart, viewEnd)
118
119	// Apply width/height constraints.
120	view = lipgloss.NewStyle().
121		Height(l.height).
122		Width(l.width).
123		Render(view)
124
125	// Apply highlighting if active.
126	if l.hasSelection() {
127		return l.renderSelection(view)
128	}
129
130	return view
131}
132
133// viewPosition returns the start and end line indices for the viewport.
134func (l *SimpleList) viewPosition() (int, int) {
135	start := max(0, l.offset)
136	end := min(l.offset+l.height-1, l.renderedHeight-1)
137	start = min(start, end)
138	return start, end
139}
140
141// getLines returns lines [start, end] from rendered content.
142func (l *SimpleList) getLines(start, end int) string {
143	if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
144		return ""
145	}
146
147	if end >= len(l.lineOffsets) {
148		end = len(l.lineOffsets) - 1
149	}
150	if start > end {
151		return ""
152	}
153
154	startOffset := l.lineOffsets[start]
155	var endOffset int
156	if end+1 < len(l.lineOffsets) {
157		endOffset = l.lineOffsets[end+1] - 1 // Exclude newline
158	} else {
159		endOffset = len(l.rendered)
160	}
161
162	if startOffset >= len(l.rendered) {
163		return ""
164	}
165	endOffset = min(endOffset, len(l.rendered))
166
167	return l.rendered[startOffset:endOffset]
168}
169
170// render rebuilds the rendered content from all items.
171func (l *SimpleList) render() tea.Cmd {
172	if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
173		return nil
174	}
175
176	// Set default selection if none.
177	if l.selectedIdx < 0 && len(l.items) > 0 {
178		l.selectedIdx = 0
179	}
180
181	// Handle focus changes.
182	var focusCmd tea.Cmd
183	if l.focused {
184		focusCmd = l.focusSelectedItem()
185	} else {
186		focusCmd = l.blurSelectedItem()
187	}
188
189	// Render all items.
190	var b strings.Builder
191	currentLine := 0
192
193	for i, item := range l.items {
194		// Render item.
195		view := l.renderItem(item)
196		height := lipgloss.Height(view)
197
198		// Store metadata.
199		rItem := renderedItem{
200			view:   view,
201			height: height,
202			start:  currentLine,
203			end:    currentLine + height - 1,
204		}
205
206		if idItem, ok := item.(interface{ ID() string }); ok {
207			l.renderedItems[idItem.ID()] = rItem
208		}
209
210		// Append to rendered content.
211		b.WriteString(view)
212
213		// Add gap after item (except last).
214		gap := l.gap
215		if i == len(l.items)-1 {
216			gap = 0
217		}
218
219		if gap > 0 {
220			if gap <= maxGapSize {
221				b.WriteString(newlineBuffer[:gap])
222			} else {
223				b.WriteString(strings.Repeat("\n", gap))
224			}
225		}
226
227		currentLine += height + gap
228	}
229
230	l.setRendered(b.String())
231
232	// Scroll to selected item.
233	if l.focused && l.selectedIdx >= 0 {
234		l.scrollToSelection()
235	}
236
237	return focusCmd
238}
239
240// renderItem renders a single item.
241func (l *SimpleList) renderItem(item Item) string {
242	// Create a buffer for the item.
243	buf := uv.NewScreenBuffer(l.width, 1000) // Max height
244	area := uv.Rect(0, 0, l.width, 1000)
245	item.Draw(&buf, area)
246
247	// Find actual height.
248	height := l.measureBufferHeight(&buf)
249	if height == 0 {
250		height = 1
251	}
252
253	// Render to string.
254	return buf.Render()
255}
256
257// measureBufferHeight finds the actual content height in a buffer.
258func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int {
259	height := buf.Height()
260
261	// Scan from bottom up to find last non-empty line.
262	for y := height - 1; y >= 0; y-- {
263		line := buf.Line(y)
264		if l.lineHasContent(line) {
265			return y + 1
266		}
267	}
268
269	return 0
270}
271
272// lineHasContent checks if a line has any non-empty cells.
273func (l *SimpleList) lineHasContent(line uv.Line) bool {
274	for x := 0; x < len(line); x++ {
275		cell := line.At(x)
276		if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " {
277			return true
278		}
279	}
280	return false
281}
282
283// setRendered updates the rendered content and caches line offsets.
284func (l *SimpleList) setRendered(rendered string) {
285	l.rendered = rendered
286	l.renderedHeight = lipgloss.Height(rendered)
287
288	// Build line offset cache.
289	if len(rendered) > 0 {
290		l.lineOffsets = make([]int, 0, l.renderedHeight)
291		l.lineOffsets = append(l.lineOffsets, 0)
292
293		offset := 0
294		for {
295			idx := strings.IndexByte(rendered[offset:], '\n')
296			if idx == -1 {
297				break
298			}
299			offset += idx + 1
300			l.lineOffsets = append(l.lineOffsets, offset)
301		}
302	} else {
303		l.lineOffsets = nil
304	}
305}
306
307// scrollToSelection scrolls to make the selected item visible.
308func (l *SimpleList) scrollToSelection() {
309	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
310		return
311	}
312
313	// Get selected item metadata.
314	var rItem *renderedItem
315	if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
316		if ri, ok := l.renderedItems[idItem.ID()]; ok {
317			rItem = &ri
318		}
319	}
320
321	if rItem == nil {
322		return
323	}
324
325	start, end := l.viewPosition()
326
327	// Already visible.
328	if rItem.start >= start && rItem.end <= end {
329		return
330	}
331
332	// Item is above viewport - scroll up.
333	if rItem.start < start {
334		l.offset = rItem.start
335		return
336	}
337
338	// Item is below viewport - scroll down.
339	if rItem.end > end {
340		l.offset = max(0, rItem.end-l.height+1)
341	}
342}
343
344// Focus/blur management.
345
346func (l *SimpleList) focusSelectedItem() tea.Cmd {
347	if l.selectedIdx < 0 || !l.focused {
348		return nil
349	}
350
351	var cmds []tea.Cmd
352
353	// Blur previous.
354	if l.prevSelectedIdx >= 0 && l.prevSelectedIdx != l.selectedIdx && l.prevSelectedIdx < len(l.items) {
355		if f, ok := l.items[l.prevSelectedIdx].(Focusable); ok && f.IsFocused() {
356			f.Blur()
357		}
358	}
359
360	// Focus current.
361	if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
362		if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() {
363			f.Focus()
364		}
365	}
366
367	l.prevSelectedIdx = l.selectedIdx
368	return tea.Batch(cmds...)
369}
370
371func (l *SimpleList) blurSelectedItem() tea.Cmd {
372	if l.selectedIdx < 0 || l.focused {
373		return nil
374	}
375
376	if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
377		if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() {
378			f.Blur()
379		}
380	}
381
382	return nil
383}
384
385// Public API.
386
387// SetSize sets the viewport dimensions.
388func (l *SimpleList) SetSize(width, height int) tea.Cmd {
389	oldWidth := l.width
390	l.width = width
391	l.height = height
392
393	if oldWidth != width {
394		// Width changed - need to re-render.
395		return l.render()
396	}
397
398	return nil
399}
400
401// Width returns the viewport width.
402func (l *SimpleList) Width() int {
403	return l.width
404}
405
406// Height returns the viewport height.
407func (l *SimpleList) Height() int {
408	return l.height
409}
410
411// GetSize returns the viewport dimensions.
412func (l *SimpleList) GetSize() (int, int) {
413	return l.width, l.height
414}
415
416// Items returns all items.
417func (l *SimpleList) Items() []Item {
418	return l.items
419}
420
421// Len returns the number of items.
422func (l *SimpleList) Len() int {
423	return len(l.items)
424}
425
426// SetItems replaces all items.
427func (l *SimpleList) SetItems(items []Item) tea.Cmd {
428	l.items = items
429	l.itemIDs = make(map[string]int, len(items))
430	l.renderedItems = make(map[string]renderedItem)
431	l.selectedIdx = -1
432	l.prevSelectedIdx = -1
433	l.offset = 0
434
435	// Build ID map.
436	for i, item := range items {
437		if idItem, ok := item.(interface{ ID() string }); ok {
438			l.itemIDs[idItem.ID()] = i
439		}
440	}
441
442	return l.render()
443}
444
445// AppendItem adds an item to the end.
446func (l *SimpleList) AppendItem(item Item) tea.Cmd {
447	l.items = append(l.items, item)
448
449	if idItem, ok := item.(interface{ ID() string }); ok {
450		l.itemIDs[idItem.ID()] = len(l.items) - 1
451	}
452
453	return l.render()
454}
455
456// PrependItem adds an item to the beginning.
457func (l *SimpleList) PrependItem(item Item) tea.Cmd {
458	l.items = append([]Item{item}, l.items...)
459
460	// Rebuild ID map (indices shifted).
461	l.itemIDs = make(map[string]int, len(l.items))
462	for i, it := range l.items {
463		if idItem, ok := it.(interface{ ID() string }); ok {
464			l.itemIDs[idItem.ID()] = i
465		}
466	}
467
468	// Adjust selection.
469	if l.selectedIdx >= 0 {
470		l.selectedIdx++
471	}
472	if l.prevSelectedIdx >= 0 {
473		l.prevSelectedIdx++
474	}
475
476	return l.render()
477}
478
479// UpdateItem replaces an item at the given index.
480func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd {
481	if idx < 0 || idx >= len(l.items) {
482		return nil
483	}
484
485	l.items[idx] = item
486
487	// Update ID map.
488	if idItem, ok := item.(interface{ ID() string }); ok {
489		l.itemIDs[idItem.ID()] = idx
490	}
491
492	return l.render()
493}
494
495// DeleteItem removes an item at the given index.
496func (l *SimpleList) DeleteItem(idx int) tea.Cmd {
497	if idx < 0 || idx >= len(l.items) {
498		return nil
499	}
500
501	l.items = append(l.items[:idx], l.items[idx+1:]...)
502
503	// Rebuild ID map (indices shifted).
504	l.itemIDs = make(map[string]int, len(l.items))
505	for i, it := range l.items {
506		if idItem, ok := it.(interface{ ID() string }); ok {
507			l.itemIDs[idItem.ID()] = i
508		}
509	}
510
511	// Adjust selection.
512	if l.selectedIdx == idx {
513		if idx > 0 {
514			l.selectedIdx = idx - 1
515		} else if len(l.items) > 0 {
516			l.selectedIdx = 0
517		} else {
518			l.selectedIdx = -1
519		}
520	} else if l.selectedIdx > idx {
521		l.selectedIdx--
522	}
523
524	if l.prevSelectedIdx == idx {
525		l.prevSelectedIdx = -1
526	} else if l.prevSelectedIdx > idx {
527		l.prevSelectedIdx--
528	}
529
530	return l.render()
531}
532
533// Focus sets the list as focused.
534func (l *SimpleList) Focus() tea.Cmd {
535	l.focused = true
536	return l.render()
537}
538
539// Blur removes focus from the list.
540func (l *SimpleList) Blur() tea.Cmd {
541	l.focused = false
542	return l.render()
543}
544
545// Focused returns whether the list is focused.
546func (l *SimpleList) Focused() bool {
547	return l.focused
548}
549
550// Selection.
551
552// Selected returns the currently selected item index.
553func (l *SimpleList) Selected() int {
554	return l.selectedIdx
555}
556
557// SelectedIndex returns the currently selected item index.
558func (l *SimpleList) SelectedIndex() int {
559	return l.selectedIdx
560}
561
562// SelectedItem returns the currently selected item.
563func (l *SimpleList) SelectedItem() Item {
564	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
565		return nil
566	}
567	return l.items[l.selectedIdx]
568}
569
570// SetSelected sets the selected item by index.
571func (l *SimpleList) SetSelected(idx int) tea.Cmd {
572	if idx < -1 || idx >= len(l.items) {
573		return nil
574	}
575
576	if l.selectedIdx == idx {
577		return nil
578	}
579
580	l.prevSelectedIdx = l.selectedIdx
581	l.selectedIdx = idx
582
583	return l.render()
584}
585
586// SelectFirst selects the first item.
587func (l *SimpleList) SelectFirst() tea.Cmd {
588	return l.SetSelected(0)
589}
590
591// SelectLast selects the last item.
592func (l *SimpleList) SelectLast() tea.Cmd {
593	if len(l.items) > 0 {
594		return l.SetSelected(len(l.items) - 1)
595	}
596	return nil
597}
598
599// SelectNext selects the next item.
600func (l *SimpleList) SelectNext() tea.Cmd {
601	if l.selectedIdx < len(l.items)-1 {
602		return l.SetSelected(l.selectedIdx + 1)
603	}
604	return nil
605}
606
607// SelectPrev selects the previous item.
608func (l *SimpleList) SelectPrev() tea.Cmd {
609	if l.selectedIdx > 0 {
610		return l.SetSelected(l.selectedIdx - 1)
611	}
612	return nil
613}
614
615// SelectNextWrap selects the next item (wraps to beginning).
616func (l *SimpleList) SelectNextWrap() tea.Cmd {
617	if len(l.items) == 0 {
618		return nil
619	}
620	nextIdx := (l.selectedIdx + 1) % len(l.items)
621	return l.SetSelected(nextIdx)
622}
623
624// SelectPrevWrap selects the previous item (wraps to end).
625func (l *SimpleList) SelectPrevWrap() tea.Cmd {
626	if len(l.items) == 0 {
627		return nil
628	}
629	prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items)
630	return l.SetSelected(prevIdx)
631}
632
633// SelectFirstInView selects the first fully visible item.
634func (l *SimpleList) SelectFirstInView() tea.Cmd {
635	if len(l.items) == 0 {
636		return nil
637	}
638
639	start, end := l.viewPosition()
640
641	for i := 0; i < len(l.items); i++ {
642		if idItem, ok := l.items[i].(interface{ ID() string }); ok {
643			if rItem, ok := l.renderedItems[idItem.ID()]; ok {
644				// Check if fully visible.
645				if rItem.start >= start && rItem.end <= end {
646					return l.SetSelected(i)
647				}
648			}
649		}
650	}
651
652	return nil
653}
654
655// SelectLastInView selects the last fully visible item.
656func (l *SimpleList) SelectLastInView() tea.Cmd {
657	if len(l.items) == 0 {
658		return nil
659	}
660
661	start, end := l.viewPosition()
662
663	for i := len(l.items) - 1; i >= 0; i-- {
664		if idItem, ok := l.items[i].(interface{ ID() string }); ok {
665			if rItem, ok := l.renderedItems[idItem.ID()]; ok {
666				// Check if fully visible.
667				if rItem.start >= start && rItem.end <= end {
668					return l.SetSelected(i)
669				}
670			}
671		}
672	}
673
674	return nil
675}
676
677// SelectedItemInView returns true if the selected item is visible.
678func (l *SimpleList) SelectedItemInView() bool {
679	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
680		return false
681	}
682
683	var rItem *renderedItem
684	if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
685		if ri, ok := l.renderedItems[idItem.ID()]; ok {
686			rItem = &ri
687		}
688	}
689
690	if rItem == nil {
691		return false
692	}
693
694	start, end := l.viewPosition()
695	return rItem.start < end && rItem.end > start
696}
697
698// Scrolling.
699
700// Offset returns the current scroll offset.
701func (l *SimpleList) Offset() int {
702	return l.offset
703}
704
705// TotalHeight returns the total height of all items.
706func (l *SimpleList) TotalHeight() int {
707	return l.renderedHeight
708}
709
710// ScrollBy scrolls by the given number of lines.
711func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd {
712	l.offset += deltaLines
713	l.clampOffset()
714	return nil
715}
716
717// ScrollToTop scrolls to the top.
718func (l *SimpleList) ScrollToTop() tea.Cmd {
719	l.offset = 0
720	return nil
721}
722
723// ScrollToBottom scrolls to the bottom.
724func (l *SimpleList) ScrollToBottom() tea.Cmd {
725	l.offset = l.renderedHeight - l.height
726	l.clampOffset()
727	return nil
728}
729
730// AtTop returns true if scrolled to the top.
731func (l *SimpleList) AtTop() bool {
732	return l.offset <= 0
733}
734
735// AtBottom returns true if scrolled to the bottom.
736func (l *SimpleList) AtBottom() bool {
737	return l.offset >= l.renderedHeight-l.height
738}
739
740// ScrollToItem scrolls to make an item visible.
741func (l *SimpleList) ScrollToItem(idx int) tea.Cmd {
742	if idx < 0 || idx >= len(l.items) {
743		return nil
744	}
745
746	var rItem *renderedItem
747	if idItem, ok := l.items[idx].(interface{ ID() string }); ok {
748		if ri, ok := l.renderedItems[idItem.ID()]; ok {
749			rItem = &ri
750		}
751	}
752
753	if rItem == nil {
754		return nil
755	}
756
757	start, end := l.viewPosition()
758
759	// Already visible.
760	if rItem.start >= start && rItem.end <= end {
761		return nil
762	}
763
764	// Above viewport.
765	if rItem.start < start {
766		l.offset = rItem.start
767		return nil
768	}
769
770	// Below viewport.
771	if rItem.end > end {
772		l.offset = rItem.end - l.height + 1
773		l.clampOffset()
774	}
775
776	return nil
777}
778
779// ScrollToSelected scrolls to the selected item.
780func (l *SimpleList) ScrollToSelected() tea.Cmd {
781	if l.selectedIdx >= 0 {
782		return l.ScrollToItem(l.selectedIdx)
783	}
784	return nil
785}
786
787func (l *SimpleList) clampOffset() {
788	maxOffset := l.renderedHeight - l.height
789	if maxOffset < 0 {
790		maxOffset = 0
791	}
792	l.offset = ordered.Clamp(l.offset, 0, maxOffset)
793}
794
795// Mouse and highlighting.
796
797// HandleMouseDown handles mouse press.
798func (l *SimpleList) HandleMouseDown(x, y int) bool {
799	if x < 0 || y < 0 || x >= l.width || y >= l.height {
800		return false
801	}
802
803	// Find item at viewport y.
804	contentY := l.offset + y
805	itemIdx := l.findItemAtLine(contentY)
806
807	if itemIdx < 0 {
808		return false
809	}
810
811	l.mouseDown = true
812	l.mouseDownItem = itemIdx
813	l.mouseDownX = x
814	l.mouseDownY = y
815	l.mouseDragItem = itemIdx
816	l.mouseDragX = x
817	l.mouseDragY = y
818
819	// Start selection.
820	l.selectionStartLine = y
821	l.selectionStartCol = x
822	l.selectionEndLine = y
823	l.selectionEndCol = x
824
825	// Select item.
826	l.SetSelected(itemIdx)
827
828	return true
829}
830
831// HandleMouseDrag handles mouse drag.
832func (l *SimpleList) HandleMouseDrag(x, y int) bool {
833	if !l.mouseDown {
834		return false
835	}
836
837	// Clamp coordinates to viewport bounds.
838	clampedX := max(0, min(x, l.width-1))
839	clampedY := max(0, min(y, l.height-1))
840
841	if clampedY >= 0 && clampedY < l.height {
842		contentY := l.offset + clampedY
843		itemIdx := l.findItemAtLine(contentY)
844		if itemIdx >= 0 {
845			l.mouseDragItem = itemIdx
846			l.mouseDragX = clampedX
847			l.mouseDragY = clampedY
848		}
849	}
850
851	// Update selection end (clamped to viewport).
852	l.selectionEndLine = clampedY
853	l.selectionEndCol = clampedX
854
855	return true
856}
857
858// HandleMouseUp handles mouse release.
859func (l *SimpleList) HandleMouseUp(x, y int) bool {
860	if !l.mouseDown {
861		return false
862	}
863
864	l.mouseDown = false
865
866	// Final selection update (clamped to viewport).
867	clampedX := max(0, min(x, l.width-1))
868	clampedY := max(0, min(y, l.height-1))
869	l.selectionEndLine = clampedY
870	l.selectionEndCol = clampedX
871
872	return true
873}
874
875// ClearHighlight clears the selection.
876func (l *SimpleList) ClearHighlight() {
877	l.selectionStartLine = -1
878	l.selectionStartCol = -1
879	l.selectionEndLine = -1
880	l.selectionEndCol = -1
881	l.mouseDown = false
882	l.mouseDownItem = -1
883	l.mouseDragItem = -1
884}
885
886// GetHighlightedText returns the selected text.
887func (l *SimpleList) GetHighlightedText() string {
888	if !l.hasSelection() {
889		return ""
890	}
891
892	return l.renderSelection(l.View())
893}
894
895func (l *SimpleList) hasSelection() bool {
896	return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
897}
898
899// renderSelection applies highlighting to the view and extracts text.
900func (l *SimpleList) renderSelection(view string) string {
901	// Create a screen buffer spanning the viewport.
902	buf := uv.NewScreenBuffer(l.width, l.height)
903	area := uv.Rect(0, 0, l.width, l.height)
904	uv.NewStyledString(view).Draw(&buf, area)
905
906	// Calculate selection bounds.
907	startLine := min(l.selectionStartLine, l.selectionEndLine)
908	endLine := max(l.selectionStartLine, l.selectionEndLine)
909	startCol := l.selectionStartCol
910	endCol := l.selectionEndCol
911
912	if l.selectionEndLine < l.selectionStartLine {
913		startCol = l.selectionEndCol
914		endCol = l.selectionStartCol
915	}
916
917	// Apply highlighting.
918	for y := startLine; y <= endLine && y < l.height; y++ {
919		if y >= buf.Height() {
920			break
921		}
922
923		line := buf.Line(y)
924
925		// Determine column range for this line.
926		colStart := 0
927		if y == startLine {
928			colStart = startCol
929		}
930
931		colEnd := len(line)
932		if y == endLine {
933			colEnd = min(endCol, len(line))
934		}
935
936		// Apply highlight style.
937		for x := colStart; x < colEnd && x < len(line); x++ {
938			cell := line.At(x)
939			if cell != nil && !cell.IsZero() {
940				cell = cell.Clone()
941				// Toggle reverse for highlight.
942				if cell.Style.Attrs&uv.AttrReverse != 0 {
943					cell.Style.Attrs &^= uv.AttrReverse
944				} else {
945					cell.Style.Attrs |= uv.AttrReverse
946				}
947				buf.SetCell(x, y, cell)
948			}
949		}
950	}
951
952	return buf.Render()
953}
954
955// findItemAtLine finds the item index at the given content line.
956func (l *SimpleList) findItemAtLine(line int) int {
957	for i := 0; i < len(l.items); i++ {
958		if idItem, ok := l.items[i].(interface{ ID() string }); ok {
959			if rItem, ok := l.renderedItems[idItem.ID()]; ok {
960				if line >= rItem.start && line <= rItem.end {
961					return i
962				}
963			}
964		}
965	}
966	return -1
967}
968
969// Render returns the view (for compatibility).
970func (l *SimpleList) Render() string {
971	return l.View()
972}