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// SelectedItemInView returns true if the selected item is currently visible in the viewport.
822func (l *List) SelectedItemInView() bool {
823 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
824 return false
825 }
826
827 // Get selected item ID and position
828 item := l.items[l.selectedIdx]
829 pos, ok := l.itemPositions[item.ID()]
830 if !ok {
831 return false
832 }
833
834 // Check if item is within viewport bounds
835 viewportStart := l.offset
836 viewportEnd := l.offset + l.height
837
838 // Item is visible if any part of it overlaps with the viewport
839 return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart
840}
841
842// clampOffset ensures offset is within valid bounds.
843func (l *List) clampOffset() {
844 l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height)
845}
846
847// focusSelectedItem focuses the currently selected item if it's focusable.
848func (l *List) focusSelectedItem() {
849 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
850 return
851 }
852
853 item := l.items[l.selectedIdx]
854 if f, ok := item.(Focusable); ok {
855 f.Focus()
856 l.dirtyItems[item.ID()] = true
857 }
858}
859
860// blurSelectedItem blurs the currently selected item if it's focusable.
861func (l *List) blurSelectedItem() {
862 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
863 return
864 }
865
866 item := l.items[l.selectedIdx]
867 if f, ok := item.(Focusable); ok {
868 f.Blur()
869 l.dirtyItems[item.ID()] = true
870 }
871}
872
873// HandleMouseDown handles mouse button press events.
874// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
875// Returns true if the event was handled.
876func (l *List) HandleMouseDown(x, y int) bool {
877 l.ensureBuilt()
878
879 // Convert viewport y to master buffer y
880 bufferY := y + l.offset
881
882 // Find which item was clicked
883 itemID, itemY := l.findItemAtPosition(bufferY)
884 if itemID == "" {
885 return false
886 }
887
888 // Calculate x position within item content
889 // For now, x is just the viewport x coordinate
890 // Items can interpret this as character offset in their content
891
892 l.mouseDown = true
893 l.mouseDownItem = itemID
894 l.mouseDownX = x
895 l.mouseDownY = itemY
896 l.mouseDragItem = itemID
897 l.mouseDragX = x
898 l.mouseDragY = itemY
899
900 // Select the clicked item
901 if idx, ok := l.indexMap[itemID]; ok {
902 l.SetSelectedIndex(idx)
903 }
904
905 return true
906}
907
908// HandleMouseDrag handles mouse drag events during selection.
909// x and y are viewport-relative coordinates.
910// Returns true if the event was handled.
911func (l *List) HandleMouseDrag(x, y int) bool {
912 if !l.mouseDown {
913 return false
914 }
915
916 l.ensureBuilt()
917
918 // Convert viewport y to master buffer y
919 bufferY := y + l.offset
920
921 // Find which item we're dragging over
922 itemID, itemY := l.findItemAtPosition(bufferY)
923 if itemID == "" {
924 return false
925 }
926
927 l.mouseDragItem = itemID
928 l.mouseDragX = x
929 l.mouseDragY = itemY
930
931 // Update highlight if item supports it
932 l.updateHighlight()
933
934 return true
935}
936
937// HandleMouseUp handles mouse button release events.
938// Returns true if the event was handled.
939func (l *List) HandleMouseUp(x, y int) bool {
940 if !l.mouseDown {
941 return false
942 }
943
944 l.mouseDown = false
945
946 // Final highlight update
947 l.updateHighlight()
948
949 return true
950}
951
952// ClearHighlight clears any active text highlighting.
953func (l *List) ClearHighlight() {
954 for _, item := range l.items {
955 if h, ok := item.(Highlightable); ok {
956 h.SetHighlight(-1, -1, -1, -1)
957 l.dirtyItems[item.ID()] = true
958 }
959 }
960}
961
962// findItemAtPosition finds the item at the given master buffer y coordinate.
963// Returns the item ID and the y offset within that item.
964func (l *List) findItemAtPosition(bufferY int) (itemID string, itemY int) {
965 if bufferY < 0 || bufferY >= l.totalHeight {
966 return "", 0
967 }
968
969 // Linear search through items to find which one contains this y
970 // This could be optimized with binary search if needed
971 for _, item := range l.items {
972 pos, ok := l.itemPositions[item.ID()]
973 if !ok {
974 continue
975 }
976
977 if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
978 return item.ID(), bufferY - pos.startLine
979 }
980 }
981
982 return "", 0
983}
984
985// updateHighlight updates the highlight range for highlightable items.
986// Supports highlighting across multiple items and respects drag direction.
987func (l *List) updateHighlight() {
988 if l.mouseDownItem == "" {
989 return
990 }
991
992 // Get start and end item indices
993 downItemIdx := l.indexMap[l.mouseDownItem]
994 dragItemIdx := l.indexMap[l.mouseDragItem]
995
996 // Determine selection direction
997 draggingDown := dragItemIdx > downItemIdx ||
998 (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
999 (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
1000
1001 // Determine actual start and end based on direction
1002 var startItemIdx, endItemIdx int
1003 var startLine, startCol, endLine, endCol int
1004
1005 if draggingDown {
1006 // Normal forward selection
1007 startItemIdx = downItemIdx
1008 endItemIdx = dragItemIdx
1009 startLine = l.mouseDownY
1010 startCol = l.mouseDownX
1011 endLine = l.mouseDragY
1012 endCol = l.mouseDragX
1013 } else {
1014 // Backward selection (dragging up)
1015 startItemIdx = dragItemIdx
1016 endItemIdx = downItemIdx
1017 startLine = l.mouseDragY
1018 startCol = l.mouseDragX
1019 endLine = l.mouseDownY
1020 endCol = l.mouseDownX
1021 }
1022
1023 // Clear all highlights first
1024 for _, item := range l.items {
1025 if h, ok := item.(Highlightable); ok {
1026 h.SetHighlight(-1, -1, -1, -1)
1027 l.dirtyItems[item.ID()] = true
1028 }
1029 }
1030
1031 // Highlight all items in range
1032 for idx := startItemIdx; idx <= endItemIdx; idx++ {
1033 item, ok := l.items[idx].(Highlightable)
1034 if !ok {
1035 continue
1036 }
1037
1038 if idx == startItemIdx && idx == endItemIdx {
1039 // Single item selection
1040 item.SetHighlight(startLine, startCol, endLine, endCol)
1041 } else if idx == startItemIdx {
1042 // First item - from start position to end of item
1043 pos := l.itemPositions[l.items[idx].ID()]
1044 item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
1045 } else if idx == endItemIdx {
1046 // Last item - from start of item to end position
1047 item.SetHighlight(0, 0, endLine, endCol)
1048 } else {
1049 // Middle item - fully highlighted
1050 pos := l.itemPositions[l.items[idx].ID()]
1051 item.SetHighlight(0, 0, pos.height-1, 9999)
1052 }
1053
1054 l.dirtyItems[l.items[idx].ID()] = true
1055 }
1056}
1057
1058// GetHighlightedText returns the plain text content of all highlighted regions
1059// across items, without any styling. Returns empty string if no highlights exist.
1060func (l *List) GetHighlightedText() string {
1061 l.ensureBuilt()
1062
1063 if l.masterBuffer == nil {
1064 return ""
1065 }
1066
1067 var result strings.Builder
1068
1069 // Iterate through items to find highlighted ones
1070 for _, item := range l.items {
1071 h, ok := item.(Highlightable)
1072 if !ok {
1073 continue
1074 }
1075
1076 startLine, startCol, endLine, endCol := h.GetHighlight()
1077 if startLine < 0 {
1078 continue
1079 }
1080
1081 pos, ok := l.itemPositions[item.ID()]
1082 if !ok {
1083 continue
1084 }
1085
1086 // Extract text from highlighted region in master buffer
1087 for y := startLine; y <= endLine && y < pos.height; y++ {
1088 bufferY := pos.startLine + y
1089 if bufferY >= l.masterBuffer.Height() {
1090 break
1091 }
1092
1093 line := l.masterBuffer.Line(bufferY)
1094
1095 // Determine column range for this line
1096 colStart := 0
1097 if y == startLine {
1098 colStart = startCol
1099 }
1100
1101 colEnd := len(line)
1102 if y == endLine {
1103 colEnd = min(endCol, len(line))
1104 }
1105
1106 // Track last non-empty position to trim trailing spaces
1107 lastContentX := -1
1108 for x := colStart; x < colEnd && x < len(line); x++ {
1109 cell := line.At(x)
1110 if cell == nil || cell.IsZero() {
1111 continue
1112 }
1113 if cell.Content != "" && cell.Content != " " {
1114 lastContentX = x
1115 }
1116 }
1117
1118 // Extract text from cells using String() method, up to last content
1119 endX := colEnd
1120 if lastContentX >= 0 {
1121 endX = lastContentX + 1
1122 }
1123
1124 for x := colStart; x < endX && x < len(line); x++ {
1125 cell := line.At(x)
1126 if cell == nil || cell.IsZero() {
1127 continue
1128 }
1129 result.WriteString(cell.String())
1130 }
1131
1132 // Add newline between lines (but not after the last line)
1133 if y < endLine && y < pos.height-1 {
1134 result.WriteRune('\n')
1135 }
1136 }
1137
1138 // Add newline between items if there are more highlighted items
1139 if result.Len() > 0 {
1140 result.WriteRune('\n')
1141 }
1142 }
1143
1144 // Trim trailing newline if present
1145 text := result.String()
1146 return strings.TrimSuffix(text, "\n")
1147}