1package list
2
3import (
4 "strings"
5
6 uv "github.com/charmbracelet/ultraviolet"
7 "github.com/charmbracelet/ultraviolet/screen"
8 "github.com/charmbracelet/x/exp/ordered"
9)
10
11// List is a scrollable list component that implements uv.Drawable.
12// It efficiently manages a large number of items by caching rendered content
13// in a master buffer and extracting only the visible viewport when drawn.
14type List struct {
15 // Configuration
16 width, height int
17
18 // Data
19 items []Item
20 indexMap map[string]int // ID -> index for fast lookup
21
22 // Focus & Selection
23 focused bool
24 selectedIdx int // Currently selected item index (-1 if none)
25
26 // Master buffer containing ALL rendered items
27 masterBuffer *uv.ScreenBuffer
28 totalHeight int
29
30 // Item positioning in master buffer
31 itemPositions map[string]itemPosition
32
33 // Viewport state
34 offset int // Scroll offset in lines from top
35
36 // Mouse state
37 mouseDown bool
38 mouseDownItem string // Item ID where mouse was pressed
39 mouseDownX int // X position in item content (character offset)
40 mouseDownY int // Y position in item (line offset)
41 mouseDragItem string // Current item being dragged over
42 mouseDragX int // Current X in item content
43 mouseDragY int // Current Y in item
44
45 // Dirty tracking
46 dirty bool
47 dirtyItems map[string]bool
48}
49
50type itemPosition struct {
51 startLine int
52 height int
53}
54
55// New creates a new list with the given items.
56func New(items ...Item) *List {
57 l := &List{
58 items: items,
59 indexMap: make(map[string]int, len(items)),
60 itemPositions: make(map[string]itemPosition, len(items)),
61 dirtyItems: make(map[string]bool),
62 selectedIdx: -1,
63 }
64
65 // Build index map
66 for i, item := range items {
67 l.indexMap[item.ID()] = i
68 }
69
70 l.dirty = true
71 return l
72}
73
74// ensureBuilt ensures the master buffer is built.
75// This is called by methods that need itemPositions or totalHeight.
76func (l *List) ensureBuilt() {
77 if l.width <= 0 || l.height <= 0 {
78 return
79 }
80
81 if l.dirty {
82 l.rebuildMasterBuffer()
83 } else if len(l.dirtyItems) > 0 {
84 l.updateDirtyItems()
85 }
86}
87
88// Draw implements uv.Drawable.
89// Draws the visible viewport of the list to the given screen buffer.
90func (l *List) Draw(scr uv.Screen, area uv.Rectangle) {
91 if area.Dx() <= 0 || area.Dy() <= 0 {
92 return
93 }
94
95 // Update internal dimensions if area size changed
96 widthChanged := l.width != area.Dx()
97 heightChanged := l.height != area.Dy()
98
99 l.width = area.Dx()
100 l.height = area.Dy()
101
102 // Only width changes require rebuilding master buffer
103 // Height changes only affect viewport clipping, not item rendering
104 if widthChanged {
105 l.dirty = true
106 }
107
108 // Height changes require clamping offset to new bounds
109 if heightChanged {
110 l.clampOffset()
111 }
112
113 if len(l.items) == 0 {
114 screen.ClearArea(scr, area)
115 return
116 }
117
118 // Ensure buffer is built
119 l.ensureBuilt()
120
121 // Draw visible portion to the target screen
122 l.drawViewport(scr, area)
123}
124
125// Render renders the visible viewport to a string.
126// This is a convenience method that creates a temporary screen buffer,
127// draws to it, and returns the rendered string.
128func (l *List) Render() string {
129 if l.width <= 0 || l.height <= 0 {
130 return ""
131 }
132
133 if len(l.items) == 0 {
134 return ""
135 }
136
137 // Ensure buffer is built
138 l.ensureBuilt()
139
140 // Extract visible lines directly from master buffer
141 return l.renderViewport()
142}
143
144// renderViewport renders the visible portion of the master buffer to a string.
145func (l *List) renderViewport() string {
146 if l.masterBuffer == nil {
147 return ""
148 }
149
150 buf := l.masterBuffer.Buffer
151
152 // Calculate visible region in master buffer
153 srcStartY := l.offset
154 srcEndY := l.offset + l.height
155
156 // Clamp to actual buffer bounds
157 if srcStartY >= len(buf.Lines) {
158 // Beyond end of content, return empty lines
159 emptyLine := strings.Repeat(" ", l.width)
160 lines := make([]string, l.height)
161 for i := range lines {
162 lines[i] = emptyLine
163 }
164 return strings.Join(lines, "\n")
165 }
166 if srcEndY > len(buf.Lines) {
167 srcEndY = len(buf.Lines)
168 }
169
170 // Build result with proper line handling
171 lines := make([]string, l.height)
172 lineIdx := 0
173
174 // Render visible lines from buffer
175 for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ {
176 lines[lineIdx] = buf.Lines[y].Render()
177 lineIdx++
178 }
179
180 // Pad remaining lines with spaces to maintain viewport height
181 emptyLine := strings.Repeat(" ", l.width)
182 for ; lineIdx < l.height; lineIdx++ {
183 lines[lineIdx] = emptyLine
184 }
185
186 return strings.Join(lines, "\n")
187}
188
189// drawViewport draws the visible portion from master buffer to target screen.
190func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
191 if l.masterBuffer == nil {
192 screen.ClearArea(scr, area)
193 return
194 }
195
196 buf := l.masterBuffer.Buffer
197
198 // Calculate visible region in master buffer
199 srcStartY := l.offset
200 srcEndY := l.offset + area.Dy()
201
202 // Clamp to actual buffer bounds
203 if srcStartY >= buf.Height() {
204 screen.ClearArea(scr, area)
205 return
206 }
207 if srcEndY > buf.Height() {
208 srcEndY = buf.Height()
209 }
210
211 // Copy visible lines to target screen
212 destY := area.Min.Y
213 for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
214 line := buf.Line(srcY)
215 destX := area.Min.X
216
217 for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
218 cell := line.At(x)
219 scr.SetCell(destX, destY, cell)
220 destX++
221 }
222 destY++
223 }
224
225 // Clear any remaining area if content is shorter than viewport
226 if destY < area.Max.Y {
227 clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY)
228 screen.ClearArea(scr, clearArea)
229 }
230}
231
232// rebuildMasterBuffer composes all items into the master buffer.
233func (l *List) rebuildMasterBuffer() {
234 if len(l.items) == 0 {
235 l.totalHeight = 0
236 l.dirty = false
237 return
238 }
239
240 // Calculate total height
241 l.totalHeight = l.calculateTotalHeight()
242
243 // Create or resize master buffer
244 if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight {
245 buf := uv.NewScreenBuffer(l.width, l.totalHeight)
246 l.masterBuffer = &buf
247 }
248
249 // Clear buffer
250 screen.Clear(l.masterBuffer)
251
252 // Draw each item
253 currentY := 0
254 for _, item := range l.items {
255 itemHeight := item.Height(l.width)
256
257 // Draw item to master buffer
258 area := uv.Rect(0, currentY, l.width, itemHeight)
259 item.Draw(l.masterBuffer, area)
260
261 // Store position
262 l.itemPositions[item.ID()] = itemPosition{
263 startLine: currentY,
264 height: itemHeight,
265 }
266
267 // Advance position
268 currentY += itemHeight
269 }
270
271 l.dirty = false
272 l.dirtyItems = make(map[string]bool)
273}
274
275// updateDirtyItems efficiently updates only changed items using slice operations.
276func (l *List) updateDirtyItems() {
277 if len(l.dirtyItems) == 0 {
278 return
279 }
280
281 // Check if all dirty items have unchanged heights
282 allSameHeight := true
283 for id := range l.dirtyItems {
284 idx, ok := l.indexMap[id]
285 if !ok {
286 continue
287 }
288
289 item := l.items[idx]
290 pos, ok := l.itemPositions[id]
291 if !ok {
292 l.dirty = true
293 l.dirtyItems = make(map[string]bool)
294 l.rebuildMasterBuffer()
295 return
296 }
297
298 newHeight := item.Height(l.width)
299 if newHeight != pos.height {
300 allSameHeight = false
301 break
302 }
303 }
304
305 // Optimization: If all dirty items have unchanged heights, re-render in place
306 if allSameHeight {
307 buf := l.masterBuffer.Buffer
308 for id := range l.dirtyItems {
309 idx := l.indexMap[id]
310 item := l.items[idx]
311 pos := l.itemPositions[id]
312
313 // Clear the item's area
314 for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
315 buf.Lines[y] = uv.NewLine(l.width)
316 }
317
318 // Re-render item
319 area := uv.Rect(0, pos.startLine, l.width, pos.height)
320 item.Draw(l.masterBuffer, area)
321 }
322
323 l.dirtyItems = make(map[string]bool)
324 return
325 }
326
327 // Height changed - full rebuild
328 l.dirty = true
329 l.dirtyItems = make(map[string]bool)
330 l.rebuildMasterBuffer()
331}
332
333// updatePositionsBelow updates the startLine for all items below the given index.
334func (l *List) updatePositionsBelow(fromIdx int, delta int) {
335 for i := fromIdx + 1; i < len(l.items); i++ {
336 item := l.items[i]
337 pos := l.itemPositions[item.ID()]
338 pos.startLine += delta
339 l.itemPositions[item.ID()] = pos
340 }
341}
342
343// calculateTotalHeight calculates the total height of all items plus gaps.
344func (l *List) calculateTotalHeight() int {
345 if len(l.items) == 0 {
346 return 0
347 }
348
349 total := 0
350 for _, item := range l.items {
351 total += item.Height(l.width)
352 }
353 return total
354}
355
356// SetSize updates the viewport size.
357func (l *List) SetSize(width, height int) {
358 widthChanged := l.width != width
359 heightChanged := l.height != height
360
361 l.width = width
362 l.height = height
363
364 // Width changes require full rebuild (items may reflow)
365 if widthChanged {
366 l.dirty = true
367 }
368
369 // Height changes require clamping offset to new bounds
370 if heightChanged {
371 l.clampOffset()
372 }
373}
374
375// Height returns the current viewport height.
376func (l *List) Height() int {
377 return l.height
378}
379
380// Width returns the current viewport width.
381func (l *List) Width() int {
382 return l.width
383}
384
385// GetSize returns the current viewport size.
386func (l *List) GetSize() (int, int) {
387 return l.width, l.height
388}
389
390// Len returns the number of items in the list.
391func (l *List) Len() int {
392 return len(l.items)
393}
394
395// SetItems replaces all items in the list.
396func (l *List) SetItems(items []Item) {
397 l.items = items
398 l.indexMap = make(map[string]int, len(items))
399 l.itemPositions = make(map[string]itemPosition, len(items))
400
401 for i, item := range items {
402 l.indexMap[item.ID()] = i
403 }
404
405 l.dirty = true
406}
407
408// Items returns all items in the list.
409func (l *List) Items() []Item {
410 return l.items
411}
412
413// AppendItem adds an item to the end of the list.
414func (l *List) AppendItem(item Item) {
415 l.items = append(l.items, item)
416 l.indexMap[item.ID()] = len(l.items) - 1
417
418 // If buffer not built yet, mark dirty for full rebuild
419 if l.masterBuffer == nil || l.width <= 0 {
420 l.dirty = true
421 return
422 }
423
424 // Process any pending dirty items before modifying buffer structure
425 if len(l.dirtyItems) > 0 {
426 l.updateDirtyItems()
427 }
428
429 // Efficient append: insert lines at end of buffer
430 itemHeight := item.Height(l.width)
431 startLine := l.totalHeight
432
433 // Expand buffer
434 newLines := make([]uv.Line, itemHeight)
435 for i := range newLines {
436 newLines[i] = uv.NewLine(l.width)
437 }
438 l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...)
439
440 // Draw new item
441 area := uv.Rect(0, startLine, l.width, itemHeight)
442 item.Draw(l.masterBuffer, area)
443
444 // Update tracking
445 l.itemPositions[item.ID()] = itemPosition{
446 startLine: startLine,
447 height: itemHeight,
448 }
449 l.totalHeight += itemHeight
450}
451
452// PrependItem adds an item to the beginning of the list.
453func (l *List) PrependItem(item Item) {
454 l.items = append([]Item{item}, l.items...)
455
456 // Rebuild index map (all indices shifted)
457 l.indexMap = make(map[string]int, len(l.items))
458 for i, itm := range l.items {
459 l.indexMap[itm.ID()] = i
460 }
461
462 if l.selectedIdx >= 0 {
463 l.selectedIdx++
464 }
465
466 // If buffer not built yet, mark dirty for full rebuild
467 if l.masterBuffer == nil || l.width <= 0 {
468 l.dirty = true
469 return
470 }
471
472 // Process any pending dirty items before modifying buffer structure
473 if len(l.dirtyItems) > 0 {
474 l.updateDirtyItems()
475 }
476
477 // Efficient prepend: insert lines at start of buffer
478 itemHeight := item.Height(l.width)
479
480 // Create new lines
481 newLines := make([]uv.Line, itemHeight)
482 for i := range newLines {
483 newLines[i] = uv.NewLine(l.width)
484 }
485
486 // Insert at beginning
487 buf := l.masterBuffer.Buffer
488 buf.Lines = append(newLines, buf.Lines...)
489
490 // Draw new item
491 area := uv.Rect(0, 0, l.width, itemHeight)
492 item.Draw(l.masterBuffer, area)
493
494 // Update all positions (shift everything down)
495 for i := 1; i < len(l.items); i++ {
496 itm := l.items[i]
497 if pos, ok := l.itemPositions[itm.ID()]; ok {
498 pos.startLine += itemHeight
499 l.itemPositions[itm.ID()] = pos
500 }
501 }
502
503 // Add position for new item
504 l.itemPositions[item.ID()] = itemPosition{
505 startLine: 0,
506 height: itemHeight,
507 }
508 l.totalHeight += itemHeight
509}
510
511// UpdateItem replaces an item with the same ID.
512func (l *List) UpdateItem(id string, item Item) {
513 idx, ok := l.indexMap[id]
514 if !ok {
515 return
516 }
517
518 l.items[idx] = item
519 l.dirtyItems[id] = true
520}
521
522// DeleteItem removes an item by ID.
523func (l *List) DeleteItem(id string) {
524 idx, ok := l.indexMap[id]
525 if !ok {
526 return
527 }
528
529 // Get position before deleting
530 pos, hasPos := l.itemPositions[id]
531
532 // Process any pending dirty items before modifying buffer structure
533 if len(l.dirtyItems) > 0 {
534 l.updateDirtyItems()
535 }
536
537 l.items = append(l.items[:idx], l.items[idx+1:]...)
538 delete(l.indexMap, id)
539 delete(l.itemPositions, id)
540
541 // Rebuild index map for items after deleted one
542 for i := idx; i < len(l.items); i++ {
543 l.indexMap[l.items[i].ID()] = i
544 }
545
546 // Adjust selection
547 if l.selectedIdx == idx {
548 if idx > 0 {
549 l.selectedIdx = idx - 1
550 } else if len(l.items) > 0 {
551 l.selectedIdx = 0
552 } else {
553 l.selectedIdx = -1
554 }
555 } else if l.selectedIdx > idx {
556 l.selectedIdx--
557 }
558
559 // If buffer not built yet, mark dirty for full rebuild
560 if l.masterBuffer == nil || !hasPos {
561 l.dirty = true
562 return
563 }
564
565 // Efficient delete: remove lines from buffer
566 deleteStart := pos.startLine
567 deleteEnd := pos.startLine + pos.height
568 buf := l.masterBuffer.Buffer
569
570 if deleteEnd <= len(buf.Lines) {
571 buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...)
572 l.totalHeight -= pos.height
573 l.updatePositionsBelow(idx-1, -pos.height)
574 } else {
575 // Position data corrupt, rebuild
576 l.dirty = true
577 }
578}
579
580// Focus focuses the list and the selected item (if focusable).
581func (l *List) Focus() {
582 l.focused = true
583 l.focusSelectedItem()
584}
585
586// Blur blurs the list and the selected item (if focusable).
587func (l *List) Blur() {
588 l.focused = false
589 l.blurSelectedItem()
590}
591
592// Focused returns whether the list is focused.
593func (l *List) Focused() bool {
594 return l.focused
595}
596
597// SetSelected sets the selected item by ID.
598func (l *List) SetSelected(id string) {
599 idx, ok := l.indexMap[id]
600 if !ok {
601 return
602 }
603
604 if l.selectedIdx == idx {
605 return
606 }
607
608 prevIdx := l.selectedIdx
609 l.selectedIdx = idx
610
611 // Update focus states if list is focused
612 if l.focused {
613 if prevIdx >= 0 && prevIdx < len(l.items) {
614 if f, ok := l.items[prevIdx].(Focusable); ok {
615 f.Blur()
616 l.dirtyItems[l.items[prevIdx].ID()] = true
617 }
618 }
619
620 if f, ok := l.items[idx].(Focusable); ok {
621 f.Focus()
622 l.dirtyItems[l.items[idx].ID()] = true
623 }
624 }
625}
626
627// SetSelectedIndex sets the selected item by index.
628func (l *List) SetSelectedIndex(idx int) {
629 if idx < 0 || idx >= len(l.items) {
630 return
631 }
632 l.SetSelected(l.items[idx].ID())
633}
634
635// SelectFirst selects the first item in the list.
636func (l *List) SelectFirst() {
637 l.SetSelectedIndex(0)
638}
639
640// SelectLast selects the last item in the list.
641func (l *List) SelectLast() {
642 l.SetSelectedIndex(len(l.items) - 1)
643}
644
645// SelectNextWrap selects the next item in the list (wraps to beginning).
646// When the list is focused, skips non-focusable items.
647func (l *List) SelectNextWrap() {
648 l.selectNext(true)
649}
650
651// SelectNext selects the next item in the list (no wrap).
652// When the list is focused, skips non-focusable items.
653func (l *List) SelectNext() {
654 l.selectNext(false)
655}
656
657func (l *List) selectNext(wrap bool) {
658 if len(l.items) == 0 {
659 return
660 }
661
662 startIdx := l.selectedIdx
663 for i := 0; i < len(l.items); i++ {
664 var nextIdx int
665 if wrap {
666 nextIdx = (startIdx + 1 + i) % len(l.items)
667 } else {
668 nextIdx = startIdx + 1 + i
669 if nextIdx >= len(l.items) {
670 return
671 }
672 }
673
674 // If list is focused and item is not focusable, skip it
675 if l.focused {
676 if _, ok := l.items[nextIdx].(Focusable); !ok {
677 continue
678 }
679 }
680
681 // Select and scroll to this item
682 l.SetSelected(l.items[nextIdx].ID())
683 return
684 }
685}
686
687// SelectPrevWrap selects the previous item in the list (wraps to end).
688// When the list is focused, skips non-focusable items.
689func (l *List) SelectPrevWrap() {
690 l.selectPrev(true)
691}
692
693// SelectPrev selects the previous item in the list (no wrap).
694// When the list is focused, skips non-focusable items.
695func (l *List) SelectPrev() {
696 l.selectPrev(false)
697}
698
699func (l *List) selectPrev(wrap bool) {
700 if len(l.items) == 0 {
701 return
702 }
703
704 startIdx := l.selectedIdx
705 for i := 0; i < len(l.items); i++ {
706 var prevIdx int
707 if wrap {
708 prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items)
709 } else {
710 prevIdx = startIdx - 1 - i
711 if prevIdx < 0 {
712 return
713 }
714 }
715
716 // If list is focused and item is not focusable, skip it
717 if l.focused {
718 if _, ok := l.items[prevIdx].(Focusable); !ok {
719 continue
720 }
721 }
722
723 // Select and scroll to this item
724 l.SetSelected(l.items[prevIdx].ID())
725 return
726 }
727}
728
729// SelectedItem returns the currently selected item, or nil if none.
730func (l *List) SelectedItem() Item {
731 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
732 return nil
733 }
734 return l.items[l.selectedIdx]
735}
736
737// SelectedIndex returns the index of the currently selected item, or -1 if none.
738func (l *List) SelectedIndex() int {
739 return l.selectedIdx
740}
741
742// AtBottom returns whether the viewport is scrolled to the bottom.
743func (l *List) AtBottom() bool {
744 l.ensureBuilt()
745 return l.offset >= l.totalHeight-l.height
746}
747
748// AtTop returns whether the viewport is scrolled to the top.
749func (l *List) AtTop() bool {
750 return l.offset <= 0
751}
752
753// ScrollBy scrolls the viewport by the given number of lines.
754// Positive values scroll down, negative scroll up.
755func (l *List) ScrollBy(deltaLines int) {
756 l.offset += deltaLines
757 l.clampOffset()
758}
759
760// ScrollToTop scrolls to the top of the list.
761func (l *List) ScrollToTop() {
762 l.offset = 0
763}
764
765// ScrollToBottom scrolls to the bottom of the list.
766func (l *List) ScrollToBottom() {
767 l.ensureBuilt()
768 if l.totalHeight > l.height {
769 l.offset = l.totalHeight - l.height
770 } else {
771 l.offset = 0
772 }
773}
774
775// ScrollToItem scrolls to make the item with the given ID visible.
776func (l *List) ScrollToItem(id string) {
777 l.ensureBuilt()
778 pos, ok := l.itemPositions[id]
779 if !ok {
780 return
781 }
782
783 itemStart := pos.startLine
784 itemEnd := pos.startLine + pos.height
785 viewStart := l.offset
786 viewEnd := l.offset + l.height
787
788 // Check if item is already fully visible
789 if itemStart >= viewStart && itemEnd <= viewEnd {
790 return
791 }
792
793 // Scroll to show item
794 if itemStart < viewStart {
795 l.offset = itemStart
796 } else if itemEnd > viewEnd {
797 l.offset = itemEnd - l.height
798 }
799
800 l.clampOffset()
801}
802
803// ScrollToSelected scrolls to make the selected item visible.
804func (l *List) ScrollToSelected() {
805 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
806 return
807 }
808 l.ScrollToItem(l.items[l.selectedIdx].ID())
809}
810
811// Offset returns the current scroll offset.
812func (l *List) Offset() int {
813 return l.offset
814}
815
816// TotalHeight returns the total height of all items including gaps.
817func (l *List) TotalHeight() int {
818 return l.totalHeight
819}
820
821// SelectFirstInView selects the first item that is fully visible in the viewport.
822func (l *List) SelectFirstInView() {
823 l.ensureBuilt()
824
825 viewportStart := l.offset
826 viewportEnd := l.offset + l.height
827
828 for i, item := range l.items {
829 pos, ok := l.itemPositions[item.ID()]
830 if !ok {
831 continue
832 }
833
834 // Check if item is fully within viewport bounds
835 if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
836 l.SetSelectedIndex(i)
837 return
838 }
839 }
840}
841
842// SelectLastInView selects the last item that is fully visible in the viewport.
843func (l *List) SelectLastInView() {
844 l.ensureBuilt()
845
846 viewportStart := l.offset
847 viewportEnd := l.offset + l.height
848
849 for i := len(l.items) - 1; i >= 0; i-- {
850 item := l.items[i]
851 pos, ok := l.itemPositions[item.ID()]
852 if !ok {
853 continue
854 }
855
856 // Check if item is fully within viewport bounds
857 if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
858 l.SetSelectedIndex(i)
859 return
860 }
861 }
862}
863
864// SelectedItemInView returns true if the selected item is currently visible in the viewport.
865func (l *List) SelectedItemInView() bool {
866 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
867 return false
868 }
869
870 // Get selected item ID and position
871 item := l.items[l.selectedIdx]
872 pos, ok := l.itemPositions[item.ID()]
873 if !ok {
874 return false
875 }
876
877 // Check if item is within viewport bounds
878 viewportStart := l.offset
879 viewportEnd := l.offset + l.height
880
881 // Item is visible if any part of it overlaps with the viewport
882 return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart
883}
884
885// clampOffset ensures offset is within valid bounds.
886func (l *List) clampOffset() {
887 l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height)
888}
889
890// focusSelectedItem focuses the currently selected item if it's focusable.
891func (l *List) focusSelectedItem() {
892 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
893 return
894 }
895
896 item := l.items[l.selectedIdx]
897 if f, ok := item.(Focusable); ok {
898 f.Focus()
899 l.dirtyItems[item.ID()] = true
900 }
901}
902
903// blurSelectedItem blurs the currently selected item if it's focusable.
904func (l *List) blurSelectedItem() {
905 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
906 return
907 }
908
909 item := l.items[l.selectedIdx]
910 if f, ok := item.(Focusable); ok {
911 f.Blur()
912 l.dirtyItems[item.ID()] = true
913 }
914}
915
916// HandleMouseDown handles mouse button press events.
917// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
918// Returns true if the event was handled.
919func (l *List) HandleMouseDown(x, y int) bool {
920 l.ensureBuilt()
921
922 // Convert viewport y to master buffer y
923 bufferY := y + l.offset
924
925 // Find which item was clicked
926 itemID, itemY := l.findItemAtPosition(bufferY)
927 if itemID == "" {
928 return false
929 }
930
931 // Calculate x position within item content
932 // For now, x is just the viewport x coordinate
933 // Items can interpret this as character offset in their content
934
935 l.mouseDown = true
936 l.mouseDownItem = itemID
937 l.mouseDownX = x
938 l.mouseDownY = itemY
939 l.mouseDragItem = itemID
940 l.mouseDragX = x
941 l.mouseDragY = itemY
942
943 // Select the clicked item
944 if idx, ok := l.indexMap[itemID]; ok {
945 l.SetSelectedIndex(idx)
946 }
947
948 return true
949}
950
951// HandleMouseDrag handles mouse drag events during selection.
952// x and y are viewport-relative coordinates.
953// Returns true if the event was handled.
954func (l *List) HandleMouseDrag(x, y int) bool {
955 if !l.mouseDown {
956 return false
957 }
958
959 l.ensureBuilt()
960
961 // Convert viewport y to master buffer y
962 bufferY := y + l.offset
963
964 // Find which item we're dragging over
965 itemID, itemY := l.findItemAtPosition(bufferY)
966 if itemID == "" {
967 return false
968 }
969
970 l.mouseDragItem = itemID
971 l.mouseDragX = x
972 l.mouseDragY = itemY
973
974 // Update highlight if item supports it
975 l.updateHighlight()
976
977 return true
978}
979
980// HandleMouseUp handles mouse button release events.
981// Returns true if the event was handled.
982func (l *List) HandleMouseUp(x, y int) bool {
983 if !l.mouseDown {
984 return false
985 }
986
987 l.mouseDown = false
988
989 // Final highlight update
990 l.updateHighlight()
991
992 return true
993}
994
995// ClearHighlight clears any active text highlighting.
996func (l *List) ClearHighlight() {
997 for _, item := range l.items {
998 if h, ok := item.(Highlightable); ok {
999 h.SetHighlight(-1, -1, -1, -1)
1000 l.dirtyItems[item.ID()] = true
1001 }
1002 }
1003}
1004
1005// findItemAtPosition finds the item at the given master buffer y coordinate.
1006// Returns the item ID and the y offset within that item.
1007func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) {
1008 if bufferY < 0 || bufferY >= l.totalHeight {
1009 return "", 0
1010 }
1011
1012 // Linear search through items to find which one contains this y
1013 // This could be optimized with binary search if needed
1014 for _, item := range l.items {
1015 pos, ok := l.itemPositions[item.ID()]
1016 if !ok {
1017 continue
1018 }
1019
1020 if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
1021 return item.ID(), bufferY - pos.startLine
1022 }
1023 }
1024
1025 return "", 0
1026}
1027
1028// updateHighlight updates the highlight range for highlightable items.
1029// Supports highlighting across multiple items and respects drag direction.
1030func (l *List) updateHighlight() {
1031 if l.mouseDownItem == "" {
1032 return
1033 }
1034
1035 // Get start and end item indices
1036 downItemIdx := l.indexMap[l.mouseDownItem]
1037 dragItemIdx := l.indexMap[l.mouseDragItem]
1038
1039 // Determine selection direction
1040 draggingDown := dragItemIdx > downItemIdx ||
1041 (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
1042 (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
1043
1044 // Determine actual start and end based on direction
1045 var startItemIdx, endItemIdx int
1046 var startLine, startCol, endLine, endCol int
1047
1048 if draggingDown {
1049 // Normal forward selection
1050 startItemIdx = downItemIdx
1051 endItemIdx = dragItemIdx
1052 startLine = l.mouseDownY
1053 startCol = l.mouseDownX
1054 endLine = l.mouseDragY
1055 endCol = l.mouseDragX
1056 } else {
1057 // Backward selection (dragging up)
1058 startItemIdx = dragItemIdx
1059 endItemIdx = downItemIdx
1060 startLine = l.mouseDragY
1061 startCol = l.mouseDragX
1062 endLine = l.mouseDownY
1063 endCol = l.mouseDownX
1064 }
1065
1066 // Clear all highlights first
1067 for _, item := range l.items {
1068 if h, ok := item.(Highlightable); ok {
1069 h.SetHighlight(-1, -1, -1, -1)
1070 l.dirtyItems[item.ID()] = true
1071 }
1072 }
1073
1074 // Highlight all items in range
1075 for idx := startItemIdx; idx <= endItemIdx; idx++ {
1076 item, ok := l.items[idx].(Highlightable)
1077 if !ok {
1078 continue
1079 }
1080
1081 if idx == startItemIdx && idx == endItemIdx {
1082 // Single item selection
1083 item.SetHighlight(startLine, startCol, endLine, endCol)
1084 } else if idx == startItemIdx {
1085 // First item - from start position to end of item
1086 pos := l.itemPositions[l.items[idx].ID()]
1087 item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
1088 } else if idx == endItemIdx {
1089 // Last item - from start of item to end position
1090 item.SetHighlight(0, 0, endLine, endCol)
1091 } else {
1092 // Middle item - fully highlighted
1093 pos := l.itemPositions[l.items[idx].ID()]
1094 item.SetHighlight(0, 0, pos.height-1, 9999)
1095 }
1096
1097 l.dirtyItems[l.items[idx].ID()] = true
1098 }
1099}
1100
1101// GetHighlightedText returns the plain text content of all highlighted regions
1102// across items, without any styling. Returns empty string if no highlights exist.
1103func (l *List) GetHighlightedText() string {
1104 l.ensureBuilt()
1105
1106 if l.masterBuffer == nil {
1107 return ""
1108 }
1109
1110 var result strings.Builder
1111
1112 // Iterate through items to find highlighted ones
1113 for _, item := range l.items {
1114 h, ok := item.(Highlightable)
1115 if !ok {
1116 continue
1117 }
1118
1119 startLine, startCol, endLine, endCol := h.GetHighlight()
1120 if startLine < 0 {
1121 continue
1122 }
1123
1124 pos, ok := l.itemPositions[item.ID()]
1125 if !ok {
1126 continue
1127 }
1128
1129 // Extract text from highlighted region in master buffer
1130 for y := startLine; y <= endLine && y < pos.height; y++ {
1131 bufferY := pos.startLine + y
1132 if bufferY >= l.masterBuffer.Height() {
1133 break
1134 }
1135
1136 line := l.masterBuffer.Line(bufferY)
1137
1138 // Determine column range for this line
1139 colStart := 0
1140 if y == startLine {
1141 colStart = startCol
1142 }
1143
1144 colEnd := len(line)
1145 if y == endLine {
1146 colEnd = min(endCol, len(line))
1147 }
1148
1149 // Track last non-empty position to trim trailing spaces
1150 lastContentX := -1
1151 for x := colStart; x < colEnd && x < len(line); x++ {
1152 cell := line.At(x)
1153 if cell == nil || cell.IsZero() {
1154 continue
1155 }
1156 if cell.Content != "" && cell.Content != " " {
1157 lastContentX = x
1158 }
1159 }
1160
1161 // Extract text from cells using String() method, up to last content
1162 endX := colEnd
1163 if lastContentX >= 0 {
1164 endX = lastContentX + 1
1165 }
1166
1167 for x := colStart; x < endX && x < len(line); x++ {
1168 cell := line.At(x)
1169 if cell == nil || cell.IsZero() {
1170 continue
1171 }
1172 result.WriteString(cell.String())
1173 }
1174
1175 // Add newline between lines (but not after the last line)
1176 if y < endLine && y < pos.height-1 {
1177 result.WriteRune('\n')
1178 }
1179 }
1180
1181 // Add newline between items if there are more highlighted items
1182 if result.Len() > 0 {
1183 result.WriteRune('\n')
1184 }
1185 }
1186
1187 // Trim trailing newline if present
1188 text := result.String()
1189 return strings.TrimSuffix(text, "\n")
1190}