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