@@ -1,1126 +0,0 @@
1−package list
2−
3−import (
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.
14−type 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−
49−type itemPosition struct {
50− startLine int
51− height int
52−}
53−
54−// New creates a new list with the given items.
55−func 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.
71−func (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.
85−func (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.
123−func (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.
140−func (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.
185−func (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.
228−func (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.
271−func (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.
316−func (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.
325−func (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.
338−func (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.
357−func (l *List) Height() int {
358− return l.height
359−}
360−
361−// Width returns the current viewport width.
362−func (l *List) Width() int {
363− return l.width
364−}
365−
366−// GetSize returns the current viewport size.
367−func (l *List) GetSize() (int, int) {
368− return l.width, l.height
369−}
370−
371−// Len returns the number of items in the list.
372−func (l *List) Len() int {
373− return len(l.items)
374−}
375−
376−// SetItems replaces all items in the list.
377−func (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.
384−func (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.
389−func (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.
431−func (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.
485−func (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.
495−func (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).
548−func (l *List) Focus() {
549− l.focused = true
550− l.focusSelectedItem()
551−}
552−
553−// Blur blurs the list and the selected item (if focusable).
554−func (l *List) Blur() {
555− l.focused = false
556− l.blurSelectedItem()
557−}
558−
559−// Focused returns whether the list is focused.
560−func (l *List) Focused() bool {
561− return l.focused
562−}
563−
564−// SetSelected sets the selected item by ID.
565−func (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.
593−func (l *List) SelectFirst() {
594− l.SetSelected(0)
595−}
596−
597−// SelectLast selects the last item in the list.
598−func (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.
604−func (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.
610−func (l *List) SelectNext() {
611− l.selectNext(false)
612−}
613−
614−func (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.
646−func (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.
652−func (l *List) SelectPrev() {
653− l.selectPrev(false)
654−}
655−
656−func (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.
687−func (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.
695−func (l *List) SelectedIndex() int {
696− return l.selectedIdx
697−}
698−
699−// AtBottom returns whether the viewport is scrolled to the bottom.
700−func (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.
706−func (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.
712−func (l *List) ScrollBy(deltaLines int) {
713− l.offset += deltaLines
714− l.clampOffset()
715−}
716−
717−// ScrollToTop scrolls to the top of the list.
718−func (l *List) ScrollToTop() {
719− l.offset = 0
720−}
721−
722−// ScrollToBottom scrolls to the bottom of the list.
723−func (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.
733−func (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.
757−func (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.
765−func (l *List) Offset() int {
766− return l.offset
767−}
768−
769−// TotalHeight returns the total height of all items including gaps.
770−func (l *List) TotalHeight() int {
771− return l.totalHeight
772−}
773−
774−// SelectFirstInView selects the first item that is fully visible in the viewport.
775−func (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.
793−func (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.
811−func (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.
828−func (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.
833−func (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.
846−func (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.
861−func (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.
894−func (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.
922−func (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.
936−func (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.
950−func (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.
969−func (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.
1042−func (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−}