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